TestingBest Practices

Testing Best Practices

Master the art of writing maintainable, effective tests through proven best practices, patterns, and anti-patterns to avoid.

Introduction

Writing tests is essential, but writing good tests is an art. Well-crafted tests provide confidence, catch bugs early, and serve as living documentation. Poorly written tests become a maintenance burden and can even provide false confidence.

Good tests should be fast, isolated, repeatable, self-validating, and timely (FIRST principles).


Writing Maintainable Tests

1. Test Organization and Structure

AAA Pattern (Arrange-Act-Assert)

Every test should follow this clear structure:

// ❌ Bad: Unclear structure
it('should work', () => {
  const result = add(2, 3);
  expect(result).toBe(5);
  const result2 = add(10, 20);
  expect(result2).toBe(30);
});
 
// ✅ Good: Clear AAA structure
it('should add two numbers correctly', () => {
  // Arrange
  const a = 2;
  const b = 3;
  const expected = 5;
 
  // Act
  const result = add(a, b);
 
  // Assert
  expect(result).toBe(expected);
});

Logical Test Organization

// ✅ Good: Hierarchical organization
describe('UserService', () => {
  describe('createUser', () => {
    describe('when valid data is provided', () => {
      it('should create a new user', () => {});
      it('should hash the password', () => {});
      it('should return the created user', () => {});
    });
 
    describe('when invalid data is provided', () => {
      it('should throw error for missing email', () => {});
      it('should throw error for invalid email format', () => {});
      it('should throw error for weak password', () => {});
    });
 
    describe('when user already exists', () => {
      it('should throw error for duplicate email', () => {});
    });
  });
 
  describe('updateUser', () => {
    // Similar structure
  });
});

2. Naming Conventions

Test names should clearly describe what is being tested and what the expected outcome is.

// ❌ Bad: Vague names
it('should work', () => {});
it('test user creation', () => {});
it('should return true', () => {});
 
// ✅ Good: Descriptive names
it('should create a user with hashed password', () => {});
it('should throw ValidationError when email is missing', () => {});
it('should return true when user has admin role', () => {});
 
// ✅ Good: BDD-style naming
it('should send welcome email when user is created', () => {});
it('should not allow negative quantities in shopping cart', () => {});

Naming Patterns

PatternExample
should [expected behavior]should return user when ID is valid
when [condition], should [outcome]when user is not found, should throw NotFoundError
given [context], when [action], then [outcome]given user exists, when deleting, then user is removed

3. DRY Principle in Tests

Balance between DRY (Don’t Repeat Yourself) and test readability.

// ❌ Bad: Too much repetition
describe('Calculator', () => {
  it('should add positive numbers', () => {
    const calc = new Calculator();
    calc.initialize();
    const result = calc.add(2, 3);
    expect(result).toBe(5);
  });
 
  it('should add negative numbers', () => {
    const calc = new Calculator();
    calc.initialize();
    const result = calc.add(-2, -3);
    expect(result).toBe(-5);
  });
});
 
// ✅ Good: DRY with beforeEach
describe('Calculator', () => {
  let calculator: Calculator;
 
  beforeEach(() => {
    calculator = new Calculator();
    calculator.initialize();
  });
 
  it('should add positive numbers', () => {
    const result = calculator.add(2, 3);
    expect(result).toBe(5);
  });
 
  it('should add negative numbers', () => {
    const result = calculator.add(-2, -3);
    expect(result).toBe(-5);
  });
});
 
// ✅ Good: Helper functions for complex setup
describe('UserService', () => {
  function createTestUser(overrides = {}) {
    return {
      id: '123',
      name: 'John Doe',
      email: 'john@example.com',
      role: 'user',
      ...overrides
    };
  }
 
  it('should process admin users differently', () => {
    const admin = createTestUser({ role: 'admin' });
    const result = processUser(admin);
    expect(result.hasAdminAccess).toBe(true);
  });
 
  it('should process regular users', () => {
    const user = createTestUser();
    const result = processUser(user);
    expect(result.hasAdminAccess).toBe(false);
  });
});
⚠️

Avoid over-abstracting tests. Each test should be readable on its own without jumping between multiple helper functions.

4. Test Data Management

Test Fixtures

// fixtures/users.ts
export const fixtures = {
  validUser: {
    name: 'John Doe',
    email: 'john@example.com',
    password: 'SecurePass123!'
  },
  adminUser: {
    name: 'Admin User',
    email: 'admin@example.com',
    password: 'AdminPass123!',
    role: 'admin'
  },
  invalidUsers: {
    missingEmail: {
      name: 'John Doe',
      password: 'SecurePass123!'
    },
    invalidEmail: {
      name: 'John Doe',
      email: 'not-an-email',
      password: 'SecurePass123!'
    }
  }
};
 
// In tests
import { fixtures } from './fixtures/users';
 
it('should create user with valid data', () => {
  const user = createUser(fixtures.validUser);
  expect(user).toBeDefined();
});

Factory Functions

// factories/user.factory.ts
export class UserFactory {
  private static counter = 0;
 
  static create(overrides: Partial<User> = {}): User {
    this.counter++;
    return {
      id: `user-${this.counter}`,
      name: `Test User ${this.counter}`,
      email: `user${this.counter}@example.com`,
      createdAt: new Date(),
      ...overrides
    };
  }
 
  static createMany(count: number, overrides: Partial<User> = {}): User[] {
    return Array.from({ length: count }, () => this.create(overrides));
  }
 
  static createAdmin(): User {
    return this.create({ role: 'admin', permissions: ['read', 'write', 'delete'] });
  }
}
 
// In tests
it('should list all users', () => {
  const users = UserFactory.createMany(5);
  const result = listUsers(users);
  expect(result).toHaveLength(5);
});
 
it('should filter admin users', () => {
  const users = [
    UserFactory.create(),
    UserFactory.createAdmin(),
    UserFactory.create()
  ];
  const admins = filterAdmins(users);
  expect(admins).toHaveLength(1);
});

Builders Pattern

// builders/user.builder.ts
export class UserBuilder {
  private user: Partial<User> = {
    id: '123',
    name: 'John Doe',
    email: 'john@example.com',
    role: 'user'
  };
 
  withId(id: string): this {
    this.user.id = id;
    return this;
  }
 
  withName(name: string): this {
    this.user.name = name;
    return this;
  }
 
  withEmail(email: string): this {
    this.user.email = email;
    return this;
  }
 
  asAdmin(): this {
    this.user.role = 'admin';
    return this;
  }
 
  build(): User {
    return this.user as User;
  }
}
 
// In tests
it('should grant admin access', () => {
  const admin = new UserBuilder()
    .withEmail('admin@example.com')
    .asAdmin()
    .build();
 
  expect(hasAdminAccess(admin)).toBe(true);
});

When to Mock vs. Real Implementations

Mocking Guidelines

// ✅ Good: Mock external dependencies
describe('EmailService', () => {
  it('should send email via SMTP', async () => {
    const smtpClient = {
      sendMail: jest.fn().mockResolvedValue({ messageId: '123' })
    };
 
    const emailService = new EmailService(smtpClient);
    await emailService.send('test@example.com', 'Hello');
 
    expect(smtpClient.sendMail).toHaveBeenCalledWith({
      to: 'test@example.com',
      subject: 'Hello'
    });
  });
});
 
// ✅ Good: Mock slow or unreliable services
it('should handle payment processing', async () => {
  const paymentGateway = {
    charge: jest.fn().mockResolvedValue({ success: true })
  };
 
  const result = await processPayment(paymentGateway, 100);
  expect(result.success).toBe(true);
});
 
// ❌ Bad: Mocking too much (testing mocks, not logic)
it('should calculate total', () => {
  const calculator = {
    add: jest.fn().mockReturnValue(5)
  };
 
  const result = calculator.add(2, 3);
  expect(result).toBe(5); // This tests the mock, not real logic
});
 
// ✅ Good: Use real implementation
it('should calculate total', () => {
  const calculator = new Calculator();
  const result = calculator.add(2, 3);
  expect(result).toBe(5);
});

When to Mock

Mock WhenUse Real When
External API callsPure functions
Database operations (in unit tests)Business logic
File system operationsCalculations
Network requestsData transformations
Time-dependent functionsValidations
Random number generationSimple utilities
// Example: Mocking time
describe('Subscription', () => {
  beforeEach(() => {
    jest.useFakeTimers();
    jest.setSystemTime(new Date('2024-01-01'));
  });
 
  afterEach(() => {
    jest.useRealTimers();
  });
 
  it('should check if subscription is expired', () => {
    const subscription = {
      expiresAt: new Date('2023-12-31')
    };
 
    expect(isExpired(subscription)).toBe(true);
  });
});

Avoiding Common Anti-Patterns

1. Testing Implementation Details

// ❌ Bad: Testing internal implementation
class Counter {
  private count = 0;
 
  increment() {
    this.count = this.count + 1;
  }
 
  getCount() {
    return this.count;
  }
}
 
it('should set count property', () => {
  const counter = new Counter();
  counter.increment();
  expect(counter['count']).toBe(1); // Accessing private property
});
 
// ✅ Good: Testing public behavior
it('should increment counter', () => {
  const counter = new Counter();
  counter.increment();
  expect(counter.getCount()).toBe(1);
});

2. Fragile Selectors (in UI tests)

// ❌ Bad: Brittle CSS selectors
const button = screen.getByRole('button', { name: /submit/i });
 
// ❌ Bad: Implementation-dependent
const button = container.querySelector('.btn.btn-primary.mt-3');
 
// ✅ Good: Semantic queries
const button = screen.getByRole('button', { name: /submit/i });
const input = screen.getByLabelText(/email/i);
 
// ✅ Good: Test IDs for complex cases
const dialog = screen.getByTestId('confirmation-dialog');

3. Conditional Test Logic

// ❌ Bad: Conditional logic in tests
it('should validate user', () => {
  const user = getUser();
  
  if (user.role === 'admin') {
    expect(user.permissions).toContain('delete');
  } else {
    expect(user.permissions).not.toContain('delete');
  }
});
 
// ✅ Good: Separate tests for each case
describe('User permissions', () => {
  it('should grant delete permission to admins', () => {
    const admin = createUser({ role: 'admin' });
    expect(admin.permissions).toContain('delete');
  });
 
  it('should not grant delete permission to regular users', () => {
    const user = createUser({ role: 'user' });
    expect(user.permissions).not.toContain('delete');
  });
});

4. Multiple Assertions on Different Concerns

// ❌ Bad: Testing multiple things in one test
it('should handle user registration', async () => {
  const user = await registerUser(userData);
  
  expect(user.id).toBeDefined();
  expect(user.password).not.toBe(userData.password); // Password hashing
  expect(emailService.send).toHaveBeenCalled(); // Email sending
  expect(database.save).toHaveBeenCalled(); // Database saving
});
 
// ✅ Good: Separate concerns
describe('User registration', () => {
  it('should create user with generated ID', async () => {
    const user = await registerUser(userData);
    expect(user.id).toBeDefined();
  });
 
  it('should hash user password', async () => {
    const user = await registerUser(userData);
    expect(user.password).not.toBe(userData.password);
  });
 
  it('should send welcome email', async () => {
    await registerUser(userData);
    expect(emailService.send).toHaveBeenCalledWith(
      userData.email,
      expect.objectContaining({ subject: 'Welcome' })
    );
  });
});

5. Shared State Between Tests

// ❌ Bad: Shared mutable state
let user: User;
 
beforeAll(() => {
  user = createUser({ name: 'John' });
});
 
it('should update user name', () => {
  user.name = 'Jane';
  expect(user.name).toBe('Jane');
});
 
it('should have original name', () => {
  // This test will fail because previous test mutated user
  expect(user.name).toBe('John');
});
 
// ✅ Good: Isolated test state
describe('User updates', () => {
  let user: User;
 
  beforeEach(() => {
    user = createUser({ name: 'John' });
  });
 
  it('should update user name', () => {
    user.name = 'Jane';
    expect(user.name).toBe('Jane');
  });
 
  it('should have original name', () => {
    expect(user.name).toBe('John');
  });
});

Test Performance Optimization

1. Parallel Test Execution

# Jest - run tests in parallel (default)
jest --maxWorkers=4
 
# Run tests serially (useful for debugging)
jest --runInBand

2. Selective Test Running

// Run only specific tests during development
describe.only('Feature under development', () => {
  it('should work', () => {});
});
 
// Skip slow tests in watch mode
describe.skip('Slow integration tests', () => {
  it('should run slowly', () => {});
});
 
// Conditional skipping
const runSlowTests = process.env.RUN_SLOW_TESTS === 'true';
 
(runSlowTests ? describe : describe.skip)('Slow tests', () => {
  it('should take a long time', () => {});
});

3. Efficient Setup/Teardown

// ❌ Bad: Creating database connection for each test
describe('UserRepository', () => {
  it('should save user', async () => {
    const db = await createConnection(); // Slow
    const repo = new UserRepository(db);
    await repo.save(user);
    await db.close();
  });
 
  it('should find user', async () => {
    const db = await createConnection(); // Slow
    const repo = new UserRepository(db);
    await repo.findById('123');
    await db.close();
  });
});
 
// ✅ Good: Reuse connection
describe('UserRepository', () => {
  let db: Database;
  let repo: UserRepository;
 
  beforeAll(async () => {
    db = await createConnection();
    repo = new UserRepository(db);
  });
 
  afterAll(async () => {
    await db.close();
  });
 
  it('should save user', async () => {
    await repo.save(user);
  });
 
  it('should find user', async () => {
    await repo.findById('123');
  });
});

4. Avoid Unnecessary Async

// ❌ Bad: Unnecessary async/await
it('should add numbers', async () => {
  const result = add(2, 3);
  expect(result).toBe(5);
});
 
// ✅ Good: Synchronous when possible
it('should add numbers', () => {
  const result = add(2, 3);
  expect(result).toBe(5);
});

Managing Test Environments

Environment Variables

// config/test.config.ts
export const testConfig = {
  database: {
    host: process.env.TEST_DB_HOST || 'localhost',
    port: parseInt(process.env.TEST_DB_PORT || '5432'),
    name: process.env.TEST_DB_NAME || 'test_db'
  },
  apiUrl: process.env.TEST_API_URL || 'http://localhost:3000'
};
 
// jest.config.js
module.exports = {
  setupFiles: ['<rootDir>/config/test-env.js']
};
 
// config/test-env.js
process.env.NODE_ENV = 'test';
process.env.API_URL = 'http://localhost:3000';

Database Test Isolation

// Setup test database
beforeAll(async () => {
  await db.migrate.latest();
});
 
beforeEach(async () => {
  await db.seed.run();
});
 
afterEach(async () => {
  await db('users').truncate();
  await db('posts').truncate();
});
 
afterAll(async () => {
  await db.destroy();
});

Using Docker for Test Dependencies

# docker-compose.test.yml
version: '3.8'
services:
  test-db:
    image: postgres:14
    environment:
      POSTGRES_DB: test_db
      POSTGRES_USER: test_user
      POSTGRES_PASSWORD: test_password
    ports:
      - "5433:5432"
    tmpfs:
      - /var/lib/postgresql/data
# Run tests with Docker
docker-compose -f docker-compose.test.yml up -d
npm test
docker-compose -f docker-compose.test.yml down

Debugging Failing Tests

1. Isolate the Failing Test

// Use .only to run single test
it.only('should fail', () => {
  expect(true).toBe(false);
});

2. Add Debug Output

it('should process data', () => {
  const input = { value: 10 };
  const result = processData(input);
  
  console.log('Input:', input);
  console.log('Result:', result);
  
  expect(result.value).toBe(20);
});

3. Use Debugger

it('should calculate correctly', () => {
  const data = getData();
  
  debugger; // Add breakpoint
  
  const result = calculate(data);
  expect(result).toBe(expected);
});
 
// Run with Node debugger
// node --inspect-brk node_modules/.bin/jest --runInBand

4. Check Async Issues

// ❌ Bad: Missing await
it('should save user', () => {
  saveUser(user); // Returns promise but not awaited
  expect(db.users).toContain(user); // Runs before save completes
});
 
// ✅ Good: Proper async handling
it('should save user', async () => {
  await saveUser(user);
  expect(db.users).toContain(user);
});

5. Increase Test Timeout

// For slow tests
it('should handle long operation', async () => {
  await longRunningOperation();
}, 30000); // 30 second timeout
 
// Global timeout
jest.setTimeout(30000);

Test Patterns Comparison

PatternUse CaseProsCons
Inline SetupSimple testsClear, no abstractionRepetitive
beforeEachCommon setupDRY, automatic cleanupHidden setup
FixturesPredefined dataConsistent test dataCan become stale
FactoriesDynamic dataFlexible, realisticMore complex
BuildersComplex objectsFluent API, readableOverhead

Good vs. Bad Practices Summary

Good Practices ✅

  • Write tests before or alongside code (TDD)
  • Test behavior, not implementation
  • One assertion concept per test
  • Use descriptive test names
  • Keep tests simple and readable
  • Isolate tests from each other
  • Use appropriate mocking
  • Clean up after tests
  • Run tests frequently

Bad Practices ❌

  • Testing private methods directly
  • Multiple unrelated assertions
  • Shared mutable state
  • Excessive mocking
  • Hard-coded test data
  • Ignoring failing tests
  • Skipping edge cases
  • No cleanup after tests
  • Flaky tests

Checklist for Quality Tests

/**
 * Quality Test Checklist:
 * 
 * □ Does the test name clearly describe what is being tested?
 * □ Is the test focused on a single behavior?
 * □ Is the test independent and isolated?
 * □ Does the test use AAA pattern?
 * □ Are assertions clear and specific?
 * □ Is test data realistic and meaningful?
 * □ Are edge cases covered?
 * □ Are error scenarios tested?
 * □ Is the test fast enough?
 * □ Will the test fail if the code is broken?
 * □ Is the test maintainable?
 */


Additional Resources