Loading content...
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 (AAA) pattern divides every test into three distinct, sequential phases:
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:
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 is typically the largest section of a test. Here, you establish all preconditions necessary for the behavior you're testing. This includes:
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') );});premiumCustomer over c1; expiredDiscount over discount2createDefaultCustomer() communicates intent better than new Customer('a', 'b', 'c', true, null)beforeEach, not duplicated'john.doe@example.com' over 'test123'—meaningful data reveals intentIf 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 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:
const result = calculate(); const value = result.getValue();const response = await fetch(); const data = await response.json();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 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);});expect(x).toBe(5) over expect(x > 0).toBe(true)expect(result).toBeValidOrder() reads better than expect(isValid(result)).toBe(true)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.
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 suite1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071
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); });});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.
Understanding the AAA pattern also means recognizing when it's been violated. These anti-patterns make tests harder to read, maintain, and debug:
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);});While the basic AAA pattern covers most cases, certain situations require nuanced application:
Testing exceptions:
When testing that code throws an exception, the AAA structure looks slightly different because the Act happens inside an assertion:
1234567891011121314151617181920212223242526272829303132333435363738
// Pattern 1: Wrap act in assertion functiontest('throws when dividing by zero', () => { // Arrange const calculator = new Calculator(); // Act (wrapped) + Assert expect(() => calculator.divide(10, 0)) .toThrow(DivisionByZeroError);}); // Pattern 2: Explicit act capture with try-catch (when you need to inspect the error)test('includes dividend in error message', () => { // Arrange const calculator = new Calculator(); let thrownError: Error | null = null; // Act try { calculator.divide(42, 0); } catch (e) { thrownError = e as Error; } // Assert expect(thrownError).toBeInstanceOf(DivisionByZeroError); expect(thrownError?.message).toContain('42');}); // Pattern 3: Using async/await with rejectstest('rejects when user not found', async () => { // Arrange const userService = new UserService(mockRepo); mockRepo.findById.mockResolvedValue(null); // Act + Assert await expect(userService.getUser('unknown-id')) .rejects.toThrow(UserNotFoundError);});Testing asynchronous code:
Asynchronous operations require careful handling to ensure the Act phase completes before Assert phase executes:
1234567891011121314151617181920212223242526272829303132333435363738
// Async/await - cleanest approachtest('fetches user profile from API', async () => { // Arrange const apiClient = new ApiClient(mockHttpClient); mockHttpClient.get.mockResolvedValue({ id: 1, name: 'John' }); // Act const profile = await apiClient.fetchUserProfile(1); // Assert expect(profile.name).toBe('John');}); // Promise chain - equivalent but less readabletest('fetches user profile from API', () => { // Arrange const apiClient = new ApiClient(mockHttpClient); mockHttpClient.get.mockResolvedValue({ id: 1, name: 'John' }); // Act + Assert (chained) return apiClient.fetchUserProfile(1).then(profile => { expect(profile.name).toBe('John'); });}); // Testing that callbacks are invokedtest('calls success callback on completion', (done) => { // Arrange const processor = new AsyncProcessor(); const successCallback = jest.fn(() => { // Assert (inside callback) expect(successCallback).toHaveBeenCalledWith({ status: 'complete' }); done(); // Signal test completion }); // Act processor.process(data, successCallback);});When testing exceptions, the Act and Assert phases often merge syntactically: expect(() => act()).toThrow(). This is acceptable—the logical structure is still Arrange-Act-Assert, even if the Act is embedded within an assertion wrapper. The key is that one action is being verified.
The Arrange-Act-Assert pattern is your foundation for writing clear, maintainable tests. Let's consolidate the key principles:
What's next:
With test structure mastered, we turn to test naming conventions—how to name tests so they communicate intent instantly. Good test names serve as documentation, failure messages, and navigation aids. They transform a test suite from a collection of verification scripts into a living specification of system behavior.
You now possess a deep understanding of the Arrange-Act-Assert pattern—the universal structure for organizing test code. This structure brings consistency, readability, and maintainability to every test you write. Apply it consistently, recognize when it's violated, and your tests will be comprehensible to any developer who encounters them.