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
endshared_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