jsmanifest logojsmanifest

7 Testing Patterns Every JavaScript Developer Should Know

7 Testing Patterns Every JavaScript Developer Should Know

Master these seven essential testing patterns to write better, more maintainable tests in JavaScript. From AAA pattern to Page Objects, learn practical techniques that actually work.

While I was looking over some test suites the other day, I realized something fascinating. Most developers I know can write tests, but very few can write good tests. The difference? Patterns.

I was once guilty of treating testing as an afterthought—writing tests that broke constantly, took forever to maintain, and confused anyone who looked at them. Little did I know that the secret wasn't in the testing framework I chose, but in the patterns I used to structure those tests.

Let me share the seven testing patterns that completely changed how I approach testing in JavaScript.

Why Testing Patterns Matter More Than Testing Tools

Here's the thing about testing tools—they change constantly. Jest, Vitest, Mocha, Jasmine—they all come and go in popularity. But the patterns? Those are timeless.

When I finally decided to focus on testing patterns instead of chasing the latest testing framework, my tests became easier to write, understand, and maintain. I cannot stress this enough: a well-structured test using good patterns will outlive any testing framework.

The patterns we're covering today work regardless of whether you're using Jest, Vitest, or any other testing library. They're about organizing your thinking and your code in ways that make testing sustainable.

The Arrange-Act-Assert (AAA) Pattern: Your Testing Foundation

This is where everything starts. The AAA pattern is the foundation of every good test I write. It breaks down your test into three clear sections:

  • Arrange: Set up your test data and conditions
  • Act: Execute the code you're testing
  • Assert: Verify the results

The beauty of this pattern is that it forces you to think clearly about what you're testing. When I was writing messy tests, I'd often mix these three sections together, creating confusion. Here's what I mean:

// Bad: Everything mixed together
test('calculates total price', () => {
  const cart = { items: [{ price: 10 }, { price: 20 }] };
  expect(calculateTotal(cart)).toBe(30);
});
 
// Good: Clear AAA structure
test('calculates total price with multiple items', () => {
  // Arrange
  const cart = {
    items: [
      { price: 10, quantity: 2 },
      { price: 20, quantity: 1 }
    ]
  };
  
  // Act
  const total = calculateTotal(cart);
  
  // Assert
  expect(total).toBe(40);
});

The AAA pattern makes your tests self-documenting. Anyone can read them and immediately understand what's being tested and why.

Testing patterns in action

Test Doubles Pattern: Mocks, Stubs, and Spies Explained

I used to throw around terms like "mock" and "stub" interchangeably. Then I realized they're actually different tools for different jobs.

Stubs provide predetermined responses. They're perfect when you need to control what a dependency returns:

// Stub: Returns predictable data
const userServiceStub = {
  getUser: () => ({ id: 1, name: 'Test User' })
};
 
test('displays user name from service', () => {
  const component = new UserDisplay(userServiceStub);
  expect(component.render()).toContain('Test User');
});

Mocks verify behavior. They're what you use when you care about how something was called:

// Mock: Verifies the interaction happened
test('saves user with correct data', () => {
  const mockDatabase = {
    save: jest.fn()
  };
  
  const userService = new UserService(mockDatabase);
  userService.createUser({ name: 'John' });
  
  expect(mockDatabase.save).toHaveBeenCalledWith({
    name: 'John',
    createdAt: expect.any(Date)
  });
});

Spies watch real objects. They let the real method run while recording how it was used.

In other words, use stubs when you need fake data, mocks when you need to verify behavior, and spies when you want to observe real implementations.

Object Mother Pattern: Building Reusable Test Data

This pattern saved me countless hours. When I finally decided to stop copying and pasting test data everywhere, I discovered the Object Mother pattern.

The idea is simple: create factory functions that generate test objects with sensible defaults:

// Object Mother for test users
class TestUserMother {
  static createBasicUser(overrides = {}) {
    return {
      id: 1,
      name: 'Test User',
      email: 'test@example.com',
      role: 'user',
      isActive: true,
      ...overrides
    };
  }
  
  static createAdminUser(overrides = {}) {
    return this.createBasicUser({
      role: 'admin',
      permissions: ['read', 'write', 'delete'],
      ...overrides
    });
  }
  
  static createInactiveUser(overrides = {}) {
    return this.createBasicUser({
      isActive: false,
      deactivatedAt: new Date(),
      ...overrides
    });
  }
}
 
// Now your tests are cleaner
test('admin can delete users', () => {
  const admin = TestUserMother.createAdminUser();
  const target = TestUserMother.createBasicUser();
  
  expect(admin.canDelete(target)).toBe(true);
});

The Object Mother pattern gives you consistent test data with the flexibility to override specific properties when needed.

Parameterized Tests Pattern: Testing Multiple Scenarios Efficiently

I came across this pattern when I was writing essentially the same test ten times with different inputs. Parameterized tests let you run the same test logic with multiple sets of data.

// Instead of writing 5 separate tests
const testCases = [
  { input: 'hello', expected: 'Hello' },
  { input: 'WORLD', expected: 'World' },
  { input: 'jAvAsCrIpT', expected: 'Javascript' },
  { input: '', expected: '' },
  { input: 'a', expected: 'A' }
];
 
testCases.forEach(({ input, expected }) => {
  test(`capitalizes "${input}" to "${expected}"`, () => {
    expect(capitalize(input)).toBe(expected);
  });
});

Luckily we can also use more advanced table-driven approaches with libraries like Jest:

describe.each([
  [10, 20, 30],
  [0, 0, 0],
  [-5, 5, 0],
  [100, 200, 300]
])('add(%i, %i)', (a, b, expected) => {
  test(`returns ${expected}`, () => {
    expect(add(a, b)).toBe(expected);
  });
});

This pattern reduces duplication and makes it easy to add new test cases.

Organizing test patterns

Test Fixture Pattern: Managing Test State and Setup

Test fixtures handle the setup and teardown of your test environment. They're essential when you have expensive setup operations that you want to reuse across multiple tests.

describe('Database operations', () => {
  let database;
  let testUser;
  
  // Setup fixture
  beforeEach(async () => {
    database = await createTestDatabase();
    testUser = await database.users.create({
      name: 'Test User',
      email: 'test@example.com'
    });
  });
  
  // Cleanup fixture
  afterEach(async () => {
    await database.cleanup();
  });
  
  test('can update user email', async () => {
    await database.users.update(testUser.id, {
      email: 'newemail@example.com'
    });
    
    const updated = await database.users.findById(testUser.id);
    expect(updated.email).toBe('newemail@example.com');
  });
  
  test('can delete user', async () => {
    await database.users.delete(testUser.id);
    
    const found = await database.users.findById(testUser.id);
    expect(found).toBeNull();
  });
});

The key with fixtures is to keep them focused. Each test suite should have its own fixtures rather than sharing global state.

Builder Pattern for Tests: Crafting Complex Test Objects

When I was dealing with complex nested objects in my tests, I discovered the Builder pattern. It's wonderful for creating test objects with lots of optional properties.

class OrderBuilder {
  constructor() {
    this.order = {
      id: 1,
      items: [],
      customer: null,
      status: 'pending',
      total: 0
    };
  }
  
  withId(id) {
    this.order.id = id;
    return this;
  }
  
  withCustomer(customer) {
    this.order.customer = customer;
    return this;
  }
  
  withItem(item) {
    this.order.items.push(item);
    this.order.total += item.price * item.quantity;
    return this;
  }
  
  asCompleted() {
    this.order.status = 'completed';
    this.order.completedAt = new Date();
    return this;
  }
  
  build() {
    return this.order;
  }
}
 
// Now your tests read beautifully
test('calculates shipping for completed orders', () => {
  const order = new OrderBuilder()
    .withCustomer({ country: 'US' })
    .withItem({ price: 10, quantity: 2 })
    .withItem({ price: 15, quantity: 1 })
    .asCompleted()
    .build();
    
  expect(calculateShipping(order)).toBe(5.99);
});

The Builder pattern makes your test setup code expressive and maintainable.

Page Object Pattern: Organizing Integration Tests

This pattern is a game-changer for integration and end-to-end tests. Instead of scattering selectors and actions throughout your tests, you encapsulate them in page objects.

class LoginPage {
  constructor(page) {
    this.page = page;
    this.emailInput = 'input[name="email"]';
    this.passwordInput = 'input[name="password"]';
    this.submitButton = 'button[type="submit"]';
    this.errorMessage = '.error-message';
  }
  
  async login(email, password) {
    await this.page.fill(this.emailInput, email);
    await this.page.fill(this.passwordInput, password);
    await this.page.click(this.submitButton);
  }
  
  async getErrorMessage() {
    return this.page.textContent(this.errorMessage);
  }
}
 
// Your tests become readable and maintainable
test('shows error for invalid credentials', async () => {
  const loginPage = new LoginPage(page);
  
  await loginPage.login('wrong@example.com', 'wrongpass');
  
  expect(await loginPage.getErrorMessage())
    .toContain('Invalid credentials');
});

When the UI changes, you only update the page object instead of hunting through dozens of tests.

Choosing the Right Pattern for Your Testing Needs

Here's what I learned about choosing patterns: start with AAA for all your tests, add Object Mother when you notice repeated test data, use Builders for complex objects, and reach for Page Objects when integration tests get messy.

You don't need all these patterns in every project. I typically start with AAA and Test Fixtures, then add others as complexity grows. The key is recognizing when a pattern would make your tests clearer and more maintainable.

These patterns work together. I often combine the Object Mother pattern with Builders, or use Test Doubles within an AAA structure. The patterns are tools in your toolbox—use the ones that solve the problems you're facing.

And that concludes the end of this post! I hope you found this valuable and look out for more in the future!