Loading content...
A single test is easy to organize—it exists in a file, does its job, and life moves on. But software projects don't stay small. Before long, you have hundreds of tests, then thousands. Without thoughtful organization, this growth creates chaos: tests are impossible to find, related tests scatter across the codebase, and developers avoid writing new tests because they don't know where to put them.
Test organization is the discipline of structuring your test codebase so that it remains navigable, maintainable, and discoverable at any scale. Good organization means a developer can find all tests for a given feature in seconds, understand test relationships at a glance, and know exactly where new tests should go.
This isn't merely about tidiness—it's about productivity. Disorganized tests get neglected. Neglected tests become outdated. Outdated tests create false confidence or, worse, get disabled entirely. A well-organized test suite is a living asset; a disorganized one is technical debt.
By the end of this page, you will understand multiple strategies for organizing test files and directories, how to group tests within files using describe blocks and test classes, when to split versus combine tests, and how to structure test suites for different purposes (unit, integration, E2E). You'll leave with actionable patterns for organizing tests at every level of granularity.
The most fundamental decision in test organization is where test files live relative to production code. Two dominant strategies exist:
Strategy 1: Separate Test Directory
Tests live in a parallel directory structure that mirrors the source directory:
12345678910111213141516171819
project/├── src/│ ├── services/│ │ ├── OrderService.ts│ │ └── PaymentService.ts│ ├── models/│ │ ├── Order.ts│ │ └── Product.ts│ └── utils/│ └── Calculator.ts└── tests/ # Parallel structure ├── services/ │ ├── OrderService.test.ts │ └── PaymentService.test.ts ├── models/ │ ├── Order.test.ts │ └── Product.test.ts └── utils/ └── Calculator.test.tsAdvantages:
Disadvantages:
Strategy 2: Colocated Tests
Tests live alongside the source files they test:
123456789101112131415
project/└── src/ ├── services/ │ ├── OrderService.ts │ ├── OrderService.test.ts # Next to source │ ├── PaymentService.ts │ └── PaymentService.test.ts ├── models/ │ ├── Order.ts │ ├── Order.test.ts │ ├── Product.ts │ └── Product.test.ts └── utils/ ├── Calculator.ts └── Calculator.test.tsAdvantages:
Disadvantages:
.test. filesColocation has gained significant popularity because it honors the principle of proximity: things that change together should live together. When you modify OrderService, you immediately see OrderService.test.ts and remember to update tests. Neither approach is wrong—choose based on your ecosystem conventions and team preference.
Test file names should clearly indicate their relationship to production code and their type. Several conventions exist:
| Pattern | Example | Common In | Notes |
|---|---|---|---|
| [Name].test.ts | OrderService.test.ts | JavaScript/TypeScript, Jest | Most common in JS ecosystem |
| [Name].spec.ts | OrderService.spec.ts | Angular, Jasmine | 'spec' = specification |
| [Name]Test.ts | OrderServiceTest.ts | Java, .NET (older) | Classic enterprise pattern |
| [Name]Tests.ts | OrderServiceTests.ts | C# conventions | Plural indicates collection |
| test_[name].py | test_order_service.py | Python (pytest) | Prefix enables auto-discovery |
| [Name]_test.go | order_service_test.go | Go | Go's required convention |
Distinguish test types in file names:
When you have different types of tests for the same production code, include the type in the filename:
12345678910111213
OrderService.ts # Production code OrderService.test.ts # Unit testsOrderService.integration.test.ts # Integration tests OrderService.e2e.test.ts # End-to-end tests # Alternative patterns: OrderService.unit.spec.ts # Unit testsOrderService.int.spec.ts # Integration tests OrderServiceTests.cs # Unit tests (C#)OrderServiceIntegrationTests.cs # Integration tests (C#)Most test runners can be configured to find tests based on filename patterns. Jest defaults to **/.test.ts, pytest looks for test_.py, Go requires *_test.go. Ensure your naming convention matches your runner's expectations, or configure the runner explicitly.
Within a test file, tests should be grouped logically. Most frameworks provide mechanisms for hierarchical grouping:
Describe blocks (Jest, Mocha, Jasmine):
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950
describe('ShoppingCart', () => { // Group by method describe('addItem', () => { test('adds first item to empty cart') test('increases quantity for existing item') test('throws for negative quantity') // Nested grouping for complex scenarios describe('with promotions active', () => { test('applies BOGO promotion') test('tracks promotional items separately') }); }); describe('removeItem', () => { test('decreases item quantity') test('removes item when quantity reaches zero') test('throws when item not in cart') }); describe('calculateTotal', () => { // Group by scenario describe('without discounts', () => { test('sums all item prices') test('returns zero for empty cart') }); describe('with percentage discount', () => { test('applies discount to subtotal') test('ignores expired discounts') }); describe('with fixed discount', () => { test('subtracts discount from subtotal') test('does not go below zero') }); }); describe('checkout', () => { describe('validation', () => { test('throws for empty cart') test('throws for out-of-stock items') }); describe('order creation', () => { test('creates order with cart items') test('clears cart after successful checkout') }); });});Test classes and methods (xUnit frameworks):
In languages like C#, Java, and when using pytest classes, tests are organized via class structure:
1234567891011121314151617181920212223242526272829303132333435
// One class per method or feature areaclass ShoppingCartAddItemTests { test_AddItem_WhenCartEmpty_AddsFirstItem() {} test_AddItem_WhenItemExists_IncreasesQuantity() {} test_AddItem_WithNegativeQuantity_Throws() {}} class ShoppingCartRemoveItemTests { test_RemoveItem_DecreasesQuantity() {} test_RemoveItem_WhenQuantityZero_RemovesItem() {} test_RemoveItem_WhenNotInCart_Throws() {}} class ShoppingCartCalculateTotalTests { test_CalculateTotal_WithNoItems_ReturnsZero() {} test_CalculateTotal_SumsAllItemPrices() {}} class ShoppingCartCalculateTotalWithDiscountsTests { test_CalculateTotal_WithPercentageDiscount_AppliesDiscount() {} test_CalculateTotal_WithExpiredDiscount_IgnoresDiscount() {}} // Alternative: One test class with nested classes (C#)public class ShoppingCartTests { public class AddItem { [Fact] public void WhenCartEmpty_AddsFirstItem() {} [Fact] public void WhenItemExists_IncreasesQuantity() {} } public class RemoveItem { [Fact] public void DecreasesQuantity() {} [Fact] public void WhenQuantityZero_RemovesItem() {} }}addItem togetherAs test suites grow, you face decisions about file granularity: one large test file per production class, or multiple smaller files? Both approaches have merits.
Guidelines for splitting:
123456789101112131415161718192021
# Signs a test file should be split:- File exceeds 300-500 lines- More than 20-30 test cases- Tests require significantly different setup- Tests are for distinct behavioral categories # Example split for a complex OrderService: # Before (one large file):OrderService.test.ts # 800 lines, 50 tests # After (split by behavior area):OrderService.creation.test.ts # Order placement testsOrderService.cancellation.test.ts # Cancellation and refund tests OrderService.modification.test.ts # Edit order testsOrderService.queries.test.ts # Order lookup testsOrderService.validation.test.ts # Input validation tests # Alternative split by test type:OrderService.unit.test.ts # Fast unit testsOrderService.integration.test.ts # Database interaction testsStart with one test file per production class. Split only when the file becomes unwieldy—typically beyond 300 lines or 20 tests. Premature splitting creates navigation overhead and can fragment related tests unnecessarily.
Beyond file organization, tests can be grouped into suites or categories that cut across file boundaries. This enables running specific subsets of tests for different purposes.
Common test categories:
| Category | Purpose | When to Run | Typical Duration |
|---|---|---|---|
| Unit Tests | Fast, isolated logic tests | Every file save, pre-commit | Seconds |
| Integration Tests | Component interaction tests | Pre-push, CI builds | Minutes |
| E2E Tests | Full application flows | Before deploy, nightly | Minutes to hours |
| Smoke Tests | Critical path verification | Post-deploy, health checks | Seconds to minutes |
| Performance Tests | Speed and load verification | Scheduled, before releases | Hours |
| Regression Tests | Previously-broken scenarios | CI builds, before release | Variable |
Implementing categories:
Different frameworks provide different mechanisms for categorizing tests:
123456789101112131415161718192021222324252627282930313233343536373839404142434445
// Jest: Use describe.skip, describe.only, or directory structure// Configure in jest.config.js:module.exports = { projects: [ { displayName: 'unit', testMatch: ['**/*.test.ts'], testPathIgnorePatterns: ['integration', 'e2e'] }, { displayName: 'integration', testMatch: ['**/*.integration.test.ts'] } ]}; // Run specific suite: npm test -- --selectProjects=unit // xUnit (.NET): Use Trait attributes[Trait("Category", "Unit")]public class OrderServiceTests { [Fact] [Trait("Category", "Fast")] public void PlaceOrder_WithValidItems_CreatesOrder() { }} // JUnit 5: Use @Tag annotations@Tag("integration")class OrderServiceIntegrationTests { @Test @Tag("database") void placeOrder_persistsToDatabase() { }} // pytest: Use markers@pytest.mark.unitdef test_calculate_total(): pass @pytest.mark.integration @pytest.mark.slowdef test_database_persistence(): pass # Run specific category: pytest -m "unit and not slow"A common CI strategy: (1) Run unit tests on every commit (must pass in <1 minute), (2) Run unit + integration tests on every PR (must pass in <10 minutes), (3) Run full suite including E2E nightly or before releases. This balances fast feedback with thorough coverage.
As test suites grow, common infrastructure emerges: test data builders, mock factories, assertion helpers, and database utilities. Organizing this shared code is crucial for maintainability.
12345678910111213141516171819202122232425262728
tests/├── __fixtures__/ # Shared test data│ ├── users.json│ ├── products.json│ └── orders.json│├── __helpers__/ # Utility functions│ ├── assertions.ts # Custom assertions│ ├── mockFactories.ts # Create configured mocks│ └── testDataBuilders.ts # Builder pattern for test objects│├── __mocks__/ # Manual mocks for modules│ ├── axios.ts│ ├── database.ts│ └── emailService.ts│├── __setup__/ # Global test setup│ ├── globalSetup.ts # Runs once before all tests│ ├── globalTeardown.ts # Runs once after all tests│ └── testEnvironment.ts # Custom test environment│├── services/ # Service tests│ └── OrderService.test.ts│└── integration/ # Integration test suite ├── setup/ │ └── databaseSetup.ts └── OrderFlow.integration.test.tsTest Data Builders:
Builders create test objects with sensible defaults that can be overridden. They eliminate boilerplate and make tests more expressive:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576
// __helpers__/testDataBuilders.ts export class OrderBuilder { private order: Partial<Order> = { id: 'order-' + Math.random().toString(36).substr(2, 9), status: OrderStatus.PENDING, customerId: 'default-customer', items: [], total: 0, createdAt: new Date() }; withId(id: string): OrderBuilder { this.order.id = id; return this; } withStatus(status: OrderStatus): OrderBuilder { this.order.status = status; return this; } withItems(...items: OrderItem[]): OrderBuilder { this.order.items = items; this.order.total = items.reduce((sum, i) => sum + i.price * i.quantity, 0); return this; } forCustomer(customerId: string): OrderBuilder { this.order.customerId = customerId; return this; } cancelled(): OrderBuilder { this.order.status = OrderStatus.CANCELLED; return this; } completed(): OrderBuilder { this.order.status = OrderStatus.COMPLETED; return this; } build(): Order { return this.order as Order; } // Static factory for common scenarios static aPendingOrder(): OrderBuilder { return new OrderBuilder().withStatus(OrderStatus.PENDING); } static aCompletedOrder(): OrderBuilder { return new OrderBuilder().completed(); }} // Usage in tests:test('cancels pending order', () => { const order = new OrderBuilder() .withId('ORD-123') .withItems(widget, gadget) .build(); orderService.cancel(order); expect(order.status).toBe(OrderStatus.CANCELLED);}); // Or using static factory:test('cannot cancel completed order', () => { const order = OrderBuilder.aCompletedOrder().build(); expect(() => orderService.cancel(order)) .toThrow(CannotCancelCompletedOrderError);});Good organization enables developers to find relevant tests quickly. Consider how developers will search for tests:
Finding tests for a class:
With consistent naming, finding tests is trivial:
OrderService.test or OrderServiceTestsFinding tests for a behavior:
Descriptive test names enable behavior-based search:
discount to find all discount-related testsexpired to find expiration handlingFinding tests that failed:
Good organization helps trace failures to files:
ShoppingCart.addItem should increase quantity → Open ShoppingCart.test.ts, navigate to addItem describe block1234567891011121314151617181920212223242526272829303132333435363738394041
// File: ShoppingCart.test.ts// Clear hierarchy enables quick navigation describe('ShoppingCart', () => { // L1: Class describe('addItem', () => { // L2: Method describe('when cart is empty', () => { // L3: Scenario test('adds item with quantity 1') }); describe('when item exists', () => { test('increases quantity') test('does not duplicate item') }); }); describe('removeItem', () => { describe('when item exists', () => { test('decreases quantity') }); describe('when item not in cart', () => { test('throws error') }); }); // Consistent structure: developers know where to look // Jump to L1 heading, then L2 for method, then L3 for scenario}); /* IDE Test Explorer shows: ▼ ShoppingCart ▼ addItem ▼ when cart is empty ✓ adds item with quantity 1 ▼ when item exists ✓ increases quantity ✓ does not duplicate item ▼ removeItem ▼ when item exists ✓ decreases quantity ▼ when item not in cart ✓ throws error*/Modern IDEs show test hierarchies in test explorers. Well-organized tests with descriptive describe blocks create a navigable tree. Some IDEs also show test coverage inline—a well-organized test suite makes coverage gaps immediately visible in the file explorer.
Test organization isn't a one-time decision—it must evolve as your codebase grows. Here's how to manage that evolution:
Dealing with legacy test organization:
Inheriting a disorganized test suite? Improve incrementally:
123456789101112131415161718192021222324252627282930
// Strategy 1: Boy Scout Rule// When modifying a test file, improve its organization// Before:test('test1')test('test2') test('testAdd')test('testAddFails') // After your modifications:describe('Calculator', () => { describe('add', () => { test('returns sum of two positive numbers') test('throws for non-numeric input') });}); // Strategy 2: Parallel Structure Migration// Create new well-organized tests alongside legacy teststests/├── legacy/ # Old tests (running but frozen)│ └── OldTests.js└── v2/ # New test structure └── Calculator.test.ts // Gradually migrate tests from legacy/ to v2/// Delete legacy/ when migration complete // Strategy 3: Automated Reformatting// Some tools can reorganize test files automatically// Be careful: review changes thoroughly before committingMassive reorganization PRs are risky: hard to review, prone to merge conflicts, and can break CI configuration. Prefer incremental improvement. If a major reorganization is needed, do it during a low-activity period with the team's explicit coordination.
Test organization is the backbone of a maintainable test suite. Without it, tests become needles in a haystack; with it, they become a navigable map of your system's behavior. Let's consolidate the key principles:
Module Complete:
With this page, you have completed the Unit Testing Fundamentals module. You now understand:
These fundamentals apply across languages, frameworks, and project types. They form the foundation upon which more advanced testing concepts—test doubles, mocking, TDD—are built.
You've mastered the foundational concepts of unit testing in object-oriented systems. You understand what makes a test a unit test, how to structure it clearly with AAA, how to name it expressively, and how to organize tests at scale. These principles will serve you throughout your career as you write thousands of tests across countless projects. Next, you'll explore test doubles—mocks, stubs, and fakes—that enable testing in isolation.