Como estruturar dados de teste com factories e builders reutilizáveis

1. Por que usar factories e builders em testes?

1.1. Problemas com dados de teste inline

Dados de teste espalhados diretamente nos testes criam duplicação, fragilidade e manutenção complexa. Cada teste que cria um usuário manualmente repete a mesma estrutura, e qualquer mudança no modelo exige alterações em dezenas de arquivos.

// Problema: dados inline duplicados
test('deve criar pedido para usuário ativo', () => {
  const user = { id: 1, name: 'João', email: 'joao@test.com', active: true };
  // ... lógica do teste
});

test('deve permitir login de usuário ativo', () => {
  const user = { id: 1, name: 'João', email: 'joao@test.com', active: true };
  // ... duplicação exata
});

1.2. Benefícios de abstração

Factories e builders separam a construção do dado de seu uso, centralizando a lógica de criação e facilitando futuras alterações.

1.3. Diferença fundamental

  • Factory: função simples que retorna um objeto com valores padrão e permite overrides
  • Builder: objeto configurável com métodos encadeados para construção gradual e mais controle

2. Implementando uma Factory básica reutilizável

2.1. Estrutura mínima

Uma factory deve ter valores padrão sensíveis e aceitar parâmetros de override.

// Factory básica para usuário
function createUser(overrides = {}) {
  const defaults = {
    id: 1,
    name: 'Usuário Padrão',
    email: 'usuario@exemplo.com',
    active: true,
    role: 'user',
    createdAt: new Date('2024-01-01')
  };

  return { ...defaults, ...overrides };
}

2.2. Exemplo prático de uso

// Usando a factory com diferentes overrides
const activeUser = createUser();
const adminUser = createUser({ role: 'admin', id: 2 });
const inactiveUser = createUser({ active: false, email: 'inativo@exemplo.com' });

2.3. Boas práticas com spread operator

// Factory com validação mínima
function createProduct(overrides = {}) {
  const defaults = {
    id: generateId(),
    name: 'Produto Genérico',
    price: 29.90,
    category: 'eletrônicos',
    inStock: true
  };

  // Garantir que overrides não quebrem invariantes
  if (overrides.price !== undefined && overrides.price < 0) {
    throw new Error('Preço não pode ser negativo');
  }

  return { ...defaults, ...overrides };
}

3. Evoluindo para o padrão Builder

3.1. Conceito de builder fluente

Builders permitem configuração gradual e legível através de métodos encadeados.

3.2. Implementação passo a passo

class UserBuilder {
  constructor() {
    this.data = {
      id: 1,
      name: 'Usuário Padrão',
      email: 'usuario@exemplo.com',
      active: true,
      role: 'user',
      addresses: []
    };
  }

  withId(id) {
    this.data.id = id;
    return this;
  }

  withName(name) {
    this.data.name = name;
    return this;
  }

  withEmail(email) {
    this.data.email = email;
    return this;
  }

  withRole(role) {
    this.data.role = role;
    return this;
  }

  asAdmin() {
    return this.withRole('admin');
  }

  asInactive() {
    this.data.active = false;
    return this;
  }

  withAddress(address) {
    this.data.addresses.push(address);
    return this;
  }

  build() {
    return { ...this.data };
  }
}

// Uso do builder
const user = new UserBuilder()
  .withName('Maria Silva')
  .withEmail('maria@exemplo.com')
  .asAdmin()
  .withAddress({ street: 'Rua A', number: 123 })
  .build();

3.3. Herança de builders

class AdminBuilder extends UserBuilder {
  constructor() {
    super();
    this.data.role = 'admin';
    this.data.permissions = ['read', 'write', 'delete'];
  }

  withSpecialPermission(permission) {
    this.data.permissions.push(permission);
    return this;
  }
}

const admin = new AdminBuilder()
  .withName('Admin Principal')
  .withSpecialPermission('manage_users')
  .build();

4. Lidando com relacionamentos e dados aninhados

4.1. Factories interdependentes

function createOrder(overrides = {}) {
  const defaults = {
    id: 1,
    userId: createUser().id,
    items: [createOrderItem()],
    total: 59.90,
    status: 'pending',
    createdAt: new Date()
  };

  return { ...defaults, ...overrides };
}

function createOrderItem(overrides = {}) {
  const defaults = {
    id: 1,
    productId: createProduct().id,
    quantity: 1,
    price: 29.95
  };

  return { ...defaults, ...overrides };
}

4.2. Builders com associações

class OrderBuilder {
  constructor() {
    this.data = {
      id: 1,
      user: null,
      items: [],
      status: 'pending'
    };
  }

  withUser(userBuilder) {
    this.data.user = userBuilder.build();
    return this;
  }

  withDefaultUser() {
    return this.withUser(new UserBuilder());
  }

  withItems(itemBuilders) {
    this.data.items = itemBuilders.map(builder => builder.build());
    return this;
  }

  withDefaultItems(count = 1) {
    const items = Array.from({ length: count }, () => new OrderItemBuilder());
    return this.withItems(items);
  }

  build() {
    return { ...this.data };
  }
}

4.3. Estratégias para evitar cascatas

// Lazy evaluation para evitar criação desnecessária
class OrderBuilder {
  constructor() {
    this.data = {
      id: 1,
      userId: null,
      items: [],
      status: 'pending'
    };
    this._userBuilder = null;
  }

  withUser(builder) {
    this._userBuilder = builder;
    return this;
  }

  build() {
    const result = { ...this.data };

    // Só cria o usuário se necessário
    if (this._userBuilder) {
      result.user = this._userBuilder.build();
    }

    return result;
  }
}

5. Reutilização entre cenários de teste

5.1. Biblioteca central de factories

// factories/index.js
export { createUser } from './userFactory';
export { createOrder } from './orderFactory';
export { createProduct } from './productFactory';
export { UserBuilder, AdminBuilder, GuestBuilder } from './builders';

5.2. Cenários pré-configurados

class UserBuilder {
  static defaultAdmin() {
    return new UserBuilder().withRole('admin').withName('Admin Padrão');
  }

  static defaultGuest() {
    return new UserBuilder().withRole('guest').withName('Convidado');
  }

  static premiumUser() {
    return new UserBuilder()
      .withRole('user')
      .withName('Usuário Premium')
      .withAttribute('plan', 'premium');
  }
}

5.3. Traits para composição de comportamentos

class UserBuilder {
  withPremiumStatus() {
    this.data.plan = 'premium';
    this.data.features = ['unlimited_access', 'priority_support'];
    return this;
  }

  withVerifiedEmail() {
    this.data.emailVerified = true;
    this.data.verificationDate = new Date();
    return this;
  }

  withCompleteProfile() {
    return this
      .withPremiumStatus()
      .withVerifiedEmail()
      .withAddress({ street: 'Rua Principal', number: 100 });
  }
}

6. Integração com frameworks de teste e dados únicos

6.1. Garantindo unicidade

let userIdCounter = 0;

function createUser(overrides = {}) {
  userIdCounter++;

  const defaults = {
    id: userIdCounter,
    email: `usuario${userIdCounter}@exemplo.com`,
    name: `Usuário ${userIdCounter}`
  };

  return { ...defaults, ...overrides };
}

// Reset entre testes
beforeEach(() => {
  userIdCounter = 0;
});

6.2. Uso de bibliotecas auxiliares

import { faker } from '@faker-js/faker';

function createUser(overrides = {}) {
  const defaults = {
    id: faker.number.int({ min: 1, max: 10000 }),
    name: faker.person.fullName(),
    email: faker.internet.email(),
    phone: faker.phone.number(),
    address: {
      street: faker.location.streetAddress(),
      city: faker.location.city(),
      zipCode: faker.location.zipCode()
    }
  };

  return { ...defaults, ...overrides };
}

6.3. Integração com Jest

describe('User Service', () => {
  beforeEach(() => {
    resetCounters();
    jest.clearAllMocks();
  });

  test('deve criar usuário com dados únicos', () => {
    const user1 = createUser();
    const user2 = createUser();

    expect(user1.id).not.toBe(user2.id);
    expect(user1.email).not.toBe(user2.email);
  });
});

7. Anti-padrões e armadilhas comuns

7.1. Factories excessivamente complexas

// EVITAR: factory que esconde lógica de negócio
function createComplexUser(overrides = {}) {
  // Lógica de negócio escondida na factory
  if (overrides.role === 'admin' && !overrides.permissions) {
    overrides.permissions = getAllPermissions(); // Side effect inesperado
  }

  if (overrides.plan === 'premium' && overrides.active === undefined) {
    overrides.active = true; // Decisão de negócio oculta
  }

  return { ...defaultUser, ...overrides };
}

// PREFERIR: factory simples + builders específicos
class PremiumUserBuilder extends UserBuilder {
  constructor() {
    super();
    this.data.plan = 'premium';
    this.data.active = true;
  }
}

7.2. Overrides que quebram invariantes

// EVITAR: override que cria estado inválido
const invalidUser = createUser({ email: null }); // Invariante quebrada

// PREFERIR: validação na factory
function createUser(overrides = {}) {
  if (overrides.email === null || overrides.email === undefined) {
    throw new Error('Email é obrigatório');
  }
  // ...
}

7.3. Builders mutáveis causando efeitos colaterais

// PROBLEMA: builder compartilhado entre testes
const baseBuilder = new UserBuilder().withRole('user');

test('teste 1', () => {
  const user = baseBuilder.withName('João').build();
  // baseBuilder agora tem name = 'João'
});

test('teste 2', () => {
  const user = baseBuilder.build(); // Contém 'João' do teste anterior!
});

// SOLUÇÃO: imutabilidade via clonagem
class UserBuilder {
  withName(name) {
    const clone = new UserBuilder();
    clone.data = { ...this.data, name };
    return clone;
  }
}

8. Manutenção e evolução da estrutura de dados de teste

8.1. Versionamento de factories

// v1 - versão inicial
function createUser_v1(overrides = {}) {
  return { id: 1, name: 'User', email: 'user@test.com', ...overrides };
}

// v2 - após mudança no modelo (adicionado campo 'phone')
function createUser_v2(overrides = {}) {
  return { 
    id: 1, 
    name: 'User', 
    email: 'user@test.com',
    phone: null,
    ...overrides 
  };
}

8.2. Testes que validam factories

describe('createUser factory', () => {
  test('deve criar usuário com valores padrão', () => {
    const user = createUser();

    expect(user).toHaveProperty('id');
    expect(user).toHaveProperty('name');
    expect(user).toHaveProperty('email');
    expect(user.active).toBe(true);
  });

  test('deve aplicar overrides corretamente', () => {
    const user = createUser({ name: 'Custom', active: false });

    expect(user.name).toBe('Custom');
    expect(user.active).toBe(false);
  });
});

8.3. Documentação interna com exemplos

/**
 * Factory para criar usuários de teste.
 * 
 * @example
 * // Usuário padrão
 * const user = createUser();
 * 
 * @example
 * // Usuário admin personalizado
 * const admin = createUser({ 
 *   role: 'admin', 
 *   email: 'admin@sistema.com' 
 * });
 * 
 * @example
 * // Usuário com dados completos via builder
 * const user = new UserBuilder()
 *   .withName('Maria')
 *   .withEmail('maria@test.com')
 *   .asAdmin()
 *   .build();
 */
function createUser(overrides = {}) {
  // ...
}

Referências