Unit Testing Fundamentals
Test Structure: Arrange, Act, Assert
Read aloud is unavailable in this browser.
The Universal Grammar of Test Code
Every well-written unit test tells a story with three chapters: setup, execution, and verification. This narrative structure is so fundamental to testing that it has a name recognized across languages, frameworks, and organizations worldwide: Arrange-Act-Assert (AAA).
The AAA pattern isn't merely a stylistic preference—it's a cognitive framework that makes tests readable, maintainable, and self-documenting. When developers encounter a new test, they instinctively look for these three phases. When tests deviate from this structure, they become harder to understand, harder to debug, and harder to maintain.
Mastering AAA transforms test writing from an ad-hoc activity into a disciplined craft. Every test you write, regardless of what it tests or which framework you use, will follow this pattern—making your tests instantly comprehensible to any developer in the industry.
By the end of this page, you will understand each phase of the AAA pattern at a deep level—what belongs in each phase, common mistakes to avoid, and how to handle edge cases. You'll learn variations like Given-When-Then, when tests should deviate from strict AAA, and how to keep each phase clean and focused. This structural mastery will make your tests consistent, readable, and maintainable.
The Arrange-Act-Assert Pattern
The Arrange-Act-Assert (AAA) pattern divides every test into three distinct, sequential phases:
- Arrange: Set up everything needed for the test—create objects, configure mocks, prepare input data
- Act: Execute the single action being tested—call the method, invoke the behavior
- Assert: Verify the outcome—check return values, verify state changes, confirm interactions
This pattern, also known as 3A or Given-When-Then in Behavior-Driven Development (BDD) contexts, creates a predictable structure that makes tests immediately understandable.
123456789101112131415161718
describe('ShoppingCart', () => { test('applies percentage discount to cart total', () => { // ============ ARRANGE ============ // Set up the system under test and its dependencies const cart = new ShoppingCart(); cart.addItem(new Item('Widget', 100.00)); cart.addItem(new Item('Gadget', 50.00)); const discount = new PercentageDiscount(10); // 10% off // ============ ACT ============ // Execute the single action being tested const total = cart.calculateTotal(discount); // ============ ASSERT ============ // Verify the outcome expect(total).toBe(135.00); // 150 - 10% = 135 });});Why this structure works:
The AAA pattern leverages how humans naturally understand cause and effect:
- Context first: Before understanding an outcome, we need to know the starting conditions
- Single action: We focus on one change to the system, making causality clear
- Explicit verification: We state exactly what we expect, making intent unambiguous
This structure also directly supports debugging: when a test fails, you immediately know whether the problem is in setup (Arrange), execution (Act), or your expectations (Assert).
Many teams use blank lines to visually separate the three phases. Some use comments like // Arrange, // Act, // Assert. While the comments become redundant as you internalize the pattern, the blank-line separation remains valuable—it visually chunks the test into its logical components.
The Arrange Phase: Setting the Stage
The Arrange phase is typically the largest section of a test. Here, you establish all preconditions necessary for the behavior you're testing. This includes:
- Creating the System Under Test (SUT)
- Setting up dependencies (real collaborators or test doubles)
- Preparing input data for the action
- Configuring mock behaviors for dependencies
- Establishing any precondition state the SUT needs
The goal is to reach a state where a single action (the Act phase) can execute and produce a verifiable outcome.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960
// Example 1: Simple Arrange - direct instantiationtest('calculates area of rectangle', () => { // ARRANGE: Create the object with specific dimensions const rectangle = new Rectangle(width: 10, height: 5); // ACT const area = rectangle.calculateArea(); // ASSERT expect(area).toBe(50);}); // Example 2: Complex Arrange - multiple collaboratorstest('processes payment with discount and tax', () => { // ARRANGE: Set up all components const customer = new Customer('john-doe', CustomerTier.PREMIUM); const product = new Product('PRO-001', 'Pro Widget', 99.99); const discountPolicy = new TieredDiscountPolicy(); const taxCalculator = new StateTaxCalculator('NY'); const paymentGateway = createMockPaymentGateway(); const orderService = new OrderService( discountPolicy, taxCalculator, paymentGateway ); const orderRequest = new OrderRequest(customer, [product], quantity: 2); // ACT const result = orderService.processOrder(orderRequest); // ASSERT expect(result.success).toBe(true); expect(result.totalCharged).toBe(195.29); // After discount + tax}); // Example 3: Arrange with mock configurationtest('sends notification when order ships', () => { // ARRANGE const mockNotifier = { sendEmail: jest.fn().mockResolvedValue({ sent: true }), sendSMS: jest.fn().mockResolvedValue({ sent: true }) }; const order = new Order('ORD-123', 'customer@example.com'); order.setStatus(OrderStatus.PROCESSING); const shipmentService = new ShipmentService(mockNotifier); // ACT await shipmentService.shipOrder(order); // ASSERT expect(mockNotifier.sendEmail).toHaveBeenCalledWith( 'customer@example.com', expect.stringContaining('Your order ORD-123 has shipped') );});- •Use descriptive variable names —
premiumCustomeroverc1;expiredDiscountoverdiscount2 - •Prefer creation methods over constructors —
createDefaultCustomer()communicates intent better thannew Customer('a', 'b', 'c', true, null) - •Only arrange what's relevant to the test — Don't set up properties the test doesn't care about
- •Extract shared arrangement to setup methods — Common setup belongs in
beforeEach, not duplicated - •Make test data meaningful — Use
'john.doe@example.com'over'test123'—meaningful data reveals intent
If your Arrange phase spans 20+ lines, your test may be doing too much or your production code may need refactoring. Large Arrange phases often indicate: (1) The SUT has too many dependencies, (2) The test is actually an integration test, or (3) Setup logic should be extracted to helper methods. Aim for Arrange phases that establish context in a screenful of code or less.
The Act Phase: The Single Action
The Act phase is the heart of the test—the single action that triggers the behavior being verified. This phase should be minimal, typically a single line of code. If multiple actions are required, the test is likely testing too much.
The single-action principle:
A unit test verifies that one action produces the expected outcome. Multiple actions in the Act phase complicate causality: if the test fails, which action caused the failure? Worse, multiple actions often mean you're testing a scenario (integration) rather than a unit (behavior).
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455
// ✅ CORRECT: Single action in Act phasetest('deactivates user account', () => { // Arrange const user = new User('user-123', isActive: true); const userService = new UserService(mockRepository); // Act - ONE action userService.deactivateUser(user.id); // Assert expect(user.isActive).toBe(false);}); // ❌ INCORRECT: Multiple actions in Act phasetest('creates and activates account', () => { // Arrange const userService = new UserService(mockRepository); // Act - MULTIPLE actions (testing a scenario, not a unit) const user = userService.createUser('john@example.com'); // Action 1 userService.activateUser(user.id); // Action 2 userService.assignRole(user.id, 'admin'); // Action 3 // Assert - which action caused a failure? expect(user.isActive).toBe(true); expect(user.roles).toContain('admin');}); // ✅ CORRECT: Split into separate focused teststest('creates user account', () => { const userService = new UserService(mockRepository); const user = userService.createUser('john@example.com'); expect(user.email).toBe('john@example.com'); expect(user.isActive).toBe(false); // Created but not yet active}); test('activates user account', () => { const user = new User('user-123', email: 'john@example.com', isActive: false); const userService = new UserService(mockRepository); userService.activateUser(user.id); expect(user.isActive).toBe(true);}); test('assigns role to user', () => { const user = new User('user-123', isActive: true, roles: []); const userService = new UserService(mockRepository); userService.assignRole(user.id, 'admin'); expect(user.roles).toContain('admin');});When multiple statements are acceptable:
Some situations require multiple statements in the Act phase, but they should logically constitute a single action:
- Obtaining a result then accessing a property:
const result = calculate(); const value = result.getValue(); - Async operations with await:
const response = await fetch(); const data = await response.json(); - Fluent APIs:
const result = builder.withA().withB().build();
The key question: are these multiple independent actions, or multiple steps of a single logical action? If someone observing the system would see one thing happen, it's one action.
Often the Act phase captures a return value: const result = service.execute(). This variable then appears in the Assert phase. Name this variable meaningfully—actualTotal, createdOrder, validationResult—to make the Assert phase immediately understandable.
The Assert Phase: Verifying Outcomes
The Assert phase verifies that the action produced the expected outcome. This is where the test makes its judgment: did the behavior work correctly?
What to assert:
Assertions can verify different types of outcomes:
| Assertion Type | What It Verifies | Example | Use Case |
|---|---|---|---|
| Return value | The method returned the expected result | expect(sum(2, 3)).toBe(5) | Pure functions, calculations |
| State change | The object's state changed correctly | expect(account.balance).toBe(50) | Stateful operations |
| Exception thrown | The method threw expected exception | expect(() => fn()).toThrow() | Error handling paths |
| Interaction verification | A collaborator was called correctly | expect(mock.save).toHaveBeenCalledWith(...) | Command operations, side effects |
| No side effect | Something that shouldn't happen, didn't | expect(mock.delete).not.toHaveBeenCalled() | Conditional logic verification |
The single concept principle:
A test should verify one logical concept, but this might require multiple assertions. Consider:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152
// ✅ ACCEPTABLE: Multiple assertions verifying ONE concepttest('creates order with correct details', () => { // Arrange const orderService = new OrderService(mockRepo); const request = new OrderRequest(customerId: 'C-123', items: [widget]); // Act const order = orderService.createOrder(request); // Assert - all verifying "order was created correctly" expect(order.id).toBeDefined(); expect(order.customerId).toBe('C-123'); expect(order.items).toHaveLength(1); expect(order.status).toBe(OrderStatus.PENDING); expect(order.createdAt).toBeInstanceOf(Date);}); // ❌ PROBLEMATIC: Multiple assertions verifying DIFFERENT conceptstest('processes order', () => { const orderService = new OrderService(mockRepo, mockPayment, mockInventory); const result = orderService.processOrder(order); // Verification of order processing expect(result.status).toBe('COMPLETED'); // Verification of payment (different concept!) expect(mockPayment.charge).toHaveBeenCalled(); expect(result.paymentConfirmation).toBeDefined(); // Verification of inventory (another concept!) expect(mockInventory.reserve).toHaveBeenCalled(); // Verification of notifications (yet another!) expect(mockNotifier.send).toHaveBeenCalled();}); // ✅ BETTER: Split into focused teststest('marks order as completed when processing succeeds', () => { /* ... */ expect(result.status).toBe('COMPLETED');}); test('charges payment when processing order', () => { /* ... */ expect(mockPayment.charge).toHaveBeenCalledWith(order.total);}); test('reserves inventory when processing order', () => { /* ... */ expect(mockInventory.reserve).toHaveBeenCalledWith(order.items);});- •Use specific assertions —
expect(x).toBe(5)overexpect(x > 0).toBe(true) - •Prefer equality over truthiness — Better failure messages when they show expected vs. actual
- •Assert on behavior, not implementation — Verify what changed, not how it was implemented
- •Order assertions logically — Most important/likely to fail first, or in reading order
- •Include context in custom matchers —
expect(result).toBeValidOrder()reads better thanexpect(isValid(result)).toBe(true)
Given-When-Then: The BDD Variation
Given-When-Then (GWT) is a synonymous pattern from Behavior-Driven Development (BDD). It uses more narrative language but maps directly to AAA:
| AAA | GWT | Description |
|---|---|---|
| Arrange | Given | The initial context/preconditions |
| Act | When | The action/event that occurs |
| Assert | Then | The expected outcome |
The GWT vocabulary emphasizes behavior specification over technical testing, making tests read more like requirements documentation.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455
// BDD-style test using Given-When-Then languagedescribe('Shopping Cart', () => { describe('when applying a coupon', () => { it('should reduce total by coupon amount for valid coupon', () => { // Given - a cart with items and a valid coupon const cart = new ShoppingCart(); cart.addItem(new Item('Widget', 100.00)); const coupon = new FixedAmountCoupon('SAVE20', 20.00); // When - the coupon is applied cart.applyCoupon(coupon); const total = cart.getTotal(); // Then - the total reflects the discount expect(total).toBe(80.00); }); it('should reject expired coupons', () => { // Given - a cart and an expired coupon const cart = new ShoppingCart(); cart.addItem(new Item('Widget', 100.00)); const expiredCoupon = new FixedAmountCoupon('EXPIRED', 20.00, expiresAt: yesterday); // When - attempting to apply the expired coupon const applyResult = () => cart.applyCoupon(expiredCoupon); // Then - an error is thrown expect(applyResult).toThrow(CouponExpiredError); }); });}); // Some frameworks have explicit GWT syntaxdescribe('Account Balance', () => { given('an account with $100 balance', () => { const account = new Account(balance: 100); when('withdrawing $30', () => { account.withdraw(30); then('balance should be $70', () => { expect(account.balance).toBe(70); }); }); when('withdrawing $150', () => { const withdrawal = () => account.withdraw(150); then('should throw InsufficientFundsError', () => { expect(withdrawal).toThrow(InsufficientFundsError); }); }); });});Use Given-When-Then language when: (1) Tests serve as living documentation, (2) Non-technical stakeholders read tests, (3) Your team practices BDD, or (4) The narrative form clarifies complex scenarios. Use AAA language when tests are primarily developer-facing and brevity is preferred. The underlying structure is identical—only the vocabulary changes.
Setup and Teardown: Managing Common Arrangement
When multiple tests share common Arrange logic, duplicating it violates DRY (Don't Repeat Yourself) and creates maintenance burden. Test frameworks provide setup and teardown hooks to manage shared arrangement:
beforeEach/setUp: Runs before each test methodafterEach/tearDown: Runs after each test methodbeforeAll/setUpClass: Runs once before all tests in the suiteafterAll/tearDownClass: Runs once after all tests in the suite
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071
describe('OrderService', () => { // Shared state - reset before each test let orderService: OrderService; let mockPaymentGateway: jest.Mocked<PaymentGateway>; let mockInventoryService: jest.Mocked<InventoryService>; let mockNotificationService: jest.Mocked<NotificationService>; // beforeEach: Runs before EVERY test // Use for common Arrange that all tests need beforeEach(() => { // Create fresh mocks for each test (isolation!) mockPaymentGateway = createMock<PaymentGateway>(); mockInventoryService = createMock<InventoryService>(); mockNotificationService = createMock<NotificationService>(); // Configure default behaviors mockPaymentGateway.charge.mockResolvedValue({ success: true }); mockInventoryService.checkAvailability.mockReturnValue(true); // Create the SUT with mocked dependencies orderService = new OrderService( mockPaymentGateway, mockInventoryService, mockNotificationService ); }); // afterEach: Cleanup after each test afterEach(() => { jest.clearAllMocks(); }); test('processes order when payment succeeds', () => { // ARRANGE: Test-specific setup only const order = createTestOrder({ total: 99.99 }); // ACT const result = orderService.processOrder(order); // ASSERT expect(result.status).toBe('COMPLETED'); }); test('fails order when payment is declined', () => { // ARRANGE: Override default mock behavior for this test mockPaymentGateway.charge.mockResolvedValue({ success: false, reason: 'DECLINED' }); const order = createTestOrder({ total: 99.99 }); // ACT const result = orderService.processOrder(order); // ASSERT expect(result.status).toBe('PAYMENT_FAILED'); }); test('checks inventory before charging payment', () => { // ARRANGE const order = createTestOrder(); // ACT orderService.processOrder(order); // ASSERT: Verify call order const inventoryCallOrder = mockInventoryService.checkAvailability.mock.invocationCallOrder[0]; const paymentCallOrder = mockPaymentGateway.charge.mock.invocationCallOrder[0]; expect(inventoryCallOrder).toBeLessThan(paymentCallOrder); });});- •Put only truly common setup in beforeEach
- •Reset mutable state to ensure isolation
- •Use beforeAll sparingly—only for expensive, immutable setup
- •Prefer explicit inline arrangement when it clarifies the test
- •Putting all setup in beforeEach even when tests need different setup
- •Using class-level state without resetting
- •Complex conditional logic in setup methods
- •Hidden dependencies between setup and tests
When tests rely heavily on setup methods, readers must scroll up to understand what's happening—the setup becomes a 'mystery guest' providing unexplained context. Balance DRY concerns with readability: sometimes a small amount of duplication in the Arrange phase makes a test self-contained and understandable without hunting through setup methods.
Common AAA Anti-Patterns
Understanding the AAA pattern also means recognizing when it's been violated. These anti-patterns make tests harder to read, maintain, and debug:
- •Arrange-Assert-Act-Assert — Verifying preconditions before acting. This suggests either distrust in the Arrange phase or testing too much in one test.
- •Act-Arrange-Assert — Acting before fully setting up. Usually a copy-paste error or misunderstanding of dependencies.
- •Arrange-Act-Arrange-Act-Assert — Multiple acts with intermediate setup. Split into multiple tests, each with one act.
- •Assert-less tests — Tests that act but never assert. They always pass, verify nothing, provide false confidence.
- •Invisible Arrange — All setup hidden in beforeEach, leaving test bodies with just Act-Assert. Loses context.
- •Giant Arrange — 50 lines of setup for a one-line act. Indicates the SUT has too many dependencies or the test is really an integration test.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273
// ❌ ANTI-PATTERN: Arrange-Assert-Act-Asserttest('transfers money between accounts', () => { const fromAccount = new Account(balance: 100); const toAccount = new Account(balance: 50); // Pre-assertion (distrust of Arrange?) expect(fromAccount.balance).toBe(100); // Unnecessary! expect(toAccount.balance).toBe(50); // Unnecessary! transferService.transfer(fromAccount, toAccount, 30); expect(fromAccount.balance).toBe(70); expect(toAccount.balance).toBe(80);}); // ✅ FIXED: Trust your Arrange, just Act and Asserttest('transfers money between accounts', () => { const fromAccount = new Account(balance: 100); const toAccount = new Account(balance: 50); transferService.transfer(fromAccount, toAccount, 30); expect(fromAccount.balance).toBe(70); expect(toAccount.balance).toBe(80);}); // ❌ ANTI-PATTERN: Multiple Act sections (testing a scenario)test('user registration flow', () => { const userService = new UserService(mockRepo); // First Act const user = userService.createUser('john@example.com'); expect(user).toBeDefined(); // Intermediate Arrange const verificationCode = '12345'; // Second Act userService.verifyEmail(user.id, verificationCode); expect(user.isVerified).toBe(true); // Third Act userService.activateAccount(user.id); expect(user.isActive).toBe(true);}); // ✅ FIXED: Separate tests for each behaviortest('creates new user account', () => { const userService = new UserService(mockRepo); const user = userService.createUser('john@example.com'); expect(user.email).toBe('john@example.com'); expect(user.isVerified).toBe(false);}); test('verifies user email with correct code', () => { const user = createUnverifiedUser(); const userService = new UserService(mockRepo); userService.verifyEmail(user.id, 'valid-code'); expect(user.isVerified).toBe(true);}); test('activates verified user account', () => { const user = createVerifiedUser(); const userService = new UserService(mockRepo); userService.activateAccount(user.id); expect(user.isActive).toBe(true);});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.