Styling Guides

Test Organisation

Structuring test suites for readability, speed, and sustainable maintenance.

Overview

Test organisation determines how tests are structured, named, and co-located with source code. Well-organised tests serve as executable documentation: a reader can understand what a module does by reading its test suite. Co-location (test files next to source files) reduces navigation overhead. Descriptive names using Given-When-Then or describe/it language make failures self-explanatory.

Origin

JUnit (Beck and Gamma, 1997) established the xUnit naming convention (TestClassName, testMethodName). RSpec (David Chelimsky, 2005) introduced the describe/context/it DSL that reads as English. Jest (Facebook, 2014) adapted RSpec's style for JavaScript. The "Arrange-Act-Assert" (AAA) pattern was formalised in Microsoft C# community writing around 2008.

Examples

Jest test file structure with AAA and shared setup

// src/services/orderService.test.ts (co-located with orderService.ts)
import { OrderService } from './orderService';
import { createOrder, createUser } from '../test/factories';
import { prismaMock } from '../test/prisma-mock';

describe('OrderService', () => {
  let service: OrderService;

  beforeEach(() => {
    service = new OrderService(prismaMock);
  });

  describe('placeOrder', () => {
    it('creates an order with confirmed status when items are valid', async () => {
      // Arrange
      const user = createUser({ id: 'usr-1' });
      const items = [{ productId: 'prod-1', qty: 2, unitPrice: 1999 }];
      prismaMock.order.create.mockResolvedValue(createOrder({ status: 'confirmed' }));

      // Act
      const result = await service.placeOrder({ userId: user.id, items });

      // Assert
      expect(result.status).toBe('confirmed');
      expect(prismaMock.order.create).toHaveBeenCalledWith(
        expect.objectContaining({ data: expect.objectContaining({ userId: 'usr-1' }) })
      );
    });

    it('throws OrderError when items array is empty', async () => {
      await expect(
        service.placeOrder({ userId: 'usr-1', items: [] })
      ).rejects.toThrow('Order must contain at least one item');
    });
  });
});

Co-locating test files eliminates the parallel test directory structure that falls out of sync. prismaMock (jest-mock-extended) provides type-safe mocks that fail if the Prisma schema changes and the mock is not updated.

RSpec shared contexts and factory organisation

# spec/support/shared_contexts/authenticated_user.rb
RSpec.shared_context 'authenticated user' do
  let(:user) { create(:user, :verified) }
  let(:auth_headers) { { 'Authorization' => "Bearer #{user.auth_token}" } }
end

# spec/support/factories/order_factory.rb (FactoryBot)
FactoryBot.define do
  factory :order do
    association :customer, factory: :user
    status { :draft }
    currency { 'USD' }

    trait :with_items do
      after(:create) do |order|
        create_list(:order_item, 3, order: order)
      end
    end

    trait :placed do
      status { :placed }
      placed_at { 1.hour.ago }
    end
  end
end

# spec/requests/orders_spec.rb
RSpec.describe 'Orders API', type: :request do
  include_context 'authenticated user'

  describe 'POST /api/v1/orders' do
    context 'with valid items' do
      it 'returns 201 and the created order' do
        post '/api/v1/orders',
          params: { items: [{ product_id: 1, qty: 2 }] },
          headers: auth_headers
        expect(response).to have_http_status(:created)
        expect(json_response['data']['status']).to eq('draft')
      end
    end
  end
end

shared_context reuses authentication setup across request specs without duplication. FactoryBot traits compose factory variations; :placed can be combined with :with_items. create_list inside after(:create) builds associations correctly after the parent is persisted.

Use Cases

  • 01Test discovery: co-located tests ensure that when a file is deleted, its tests are obviously orphaned and also deleted
  • 02Coverage reporting: Istanbul and SimpleCov report per-file coverage; co-located files make the report match the source tree directly
  • 03Parallel test execution: Jest --runInBand runs tests sequentially; jest-circus with --maxWorkers=50% runs spec files in parallel. File-level isolation (each file is a process) requires no shared mutable state between files
  • 04Documentation: a well-organised describe/it tree documents every public behaviour of a module; running the suite with --verbose produces a printed specification

When Not to Use

  • //Do not co-locate tests if the build system does not exclude test files from production bundles; always configure moduleNameMapper or include/exclude patterns
  • //Do not use deeply nested describe blocks (more than 3 levels); the describe tree should mirror the public API, not implementation details
  • //Do not share mutable state between test cases; order-dependent tests produce intermittent failures when the test runner parallelises or reorders them

Technical Notes

  • Jest's --testPathPattern flag and jest.config.ts testMatch option control which files are treated as tests. Common patterns: **/*.test.ts and **/*.spec.ts
  • RSpec --format documentation prints the full describe/context/it tree as nested bullet points; this is useful as a human-readable spec report in CI output
  • Database test isolation strategies: DatabaseCleaner with :transaction strategy (default, wraps each example in a transaction and rolls back) is fastest; :deletion is needed for tests that use multiple threads or check for committed data
  • Test factories (FactoryBot, Faker) should create minimal data for each test; avoid "kitchen sink" factories that create 20 associations when the test only needs 2. Over-built factories slow test suites and obscure what data is actually under test