Unit Testing Fundamentals
Test Organization
Read aloud is unavailable in this browser.
Structure That Scales
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 Two Mirroring Strategies
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:
- Clear separation of production and test code
- Easy to exclude tests from production builds
- Tests cleanly separated for CI/CD configuration
- Common in Java, .NET, and enterprise environments
Disadvantages:
- Navigating between source and test requires jumping directories
- Relationship between files less immediately visible
- Directory structure must be kept in sync manually
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:
- Source and test always visible together in file explorer
- Moving a module automatically moves its tests
- Easy to see which files have tests (and which don't)
- Popular in React, Angular, and modern JavaScript ecosystems
Disadvantages:
- Production builds must explicitly exclude
.test.files - Mixed production and test code in same directory can feel cluttered
- Less familiar in enterprise environments
Colocation 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 Naming Conventions
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.
Grouping Tests Within Files
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() {} }}- •Group by method or behavior first — All tests for
addItemtogether - •Nest by scenario within method — 'with discounts', 'when empty', 'with invalid input'
- •Limit nesting depth — More than 3 levels becomes hard to read
- •Keep groups focused — If a group has 20+ tests, consider splitting
- •Use consistent grouping patterns — Same structure across all test files in the project
One Class or Many Files?
As test suites grow, you face decisions about file granularity: one large test file per production class, or multiple smaller files? Both approaches have merits.
- •One-to-one mapping is simple to understand
- •All tests for a class in one place
- •Easier to run all tests for a class
- •Works well for classes with moderate complexity
- •Keeps individual files smaller and focused
- •Parallel test execution at file level
- •Different setup for different test groups
- •Better for complex classes with many behaviors
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.
Test Suites and Categories
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.
Shared Test Infrastructure
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);});- •Centralize common utilities — One place for custom assertions, builders, factories
- •Use builder pattern for test objects — Expressive, default-rich, override-friendly
- •Avoid logic in test helpers — Helpers should be simple; complex logic needs its own tests
- •Document shared utilities — Developers need to find them; maintain a test utilities guide
- •Version test infrastructure carefully — Changes affect many tests; treat like production code
Test Discovery and Navigation
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:
- Search for
OrderService.testorOrderServiceTests - With colocation, tests are already visible beside source
Finding tests for a behavior:
Descriptive test names enable behavior-based search:
- Search for
discountto find all discount-related tests - Search for
expiredto find expiration handling
Finding tests that failed:
Good organization helps trace failures to files:
ShoppingCart.addItem should increase quantity→ OpenShoppingCart.test.ts, navigate toaddItemdescribe block
1234567891011121314151617181920212223242526272829303132333435363738394041
// 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.
KEEP LEARNING
There’s more to understand.
You’re reading the introduction. Create a free account to read the complete lesson, save notes and keep track of your progress.