Arrange-Act-Assert (AAA) — the universal structure of a well-written test
Concept
Arrange-Act-Assert (AAA) — the universal 3-phase structure for writing clear, readable tests.
Arrange: Set up the state needed for the test. Create fixtures, configure mocks, prepare data. Act: Execute the code being tested. Call the method, make the HTTP request, run the function. Assert: Verify the result. Check return values, database state, method calls.
Why AAA matters:
- Makes the test readable: Anyone can identify what's being tested just by scanning the three phases.
- Makes failures clear: The assertion shows exactly what was expected vs what happened.
- Prevents test code from becoming tangled logic — each phase is distinct.
Alternative names: "Given-When-Then" (BDD style) — same structure, different vocabulary. "Given" (Arrange), "When" (Act), "Then" (Assert).
One behavior per test: Each test should test ONE thing. If a test has many assertions, it might be testing too many behaviors. A failing test should tell you exactly what broke.
Common violations:
- Arrange happens in the middle of the test.
- Multiple Acts (testing two behaviors in one test).
- Assert before Act (wrong order).
- Giant tests with many behaviors — split them up.
Comments: Some developers add // Arrange, // Act, // Assert comments. Others prefer empty lines as separators. Either way, the structure should be clear.
Code Example
<?php
class PricingEngineTest extends \PHPUnit\Framework\TestCase
{
private PricingEngine $engine;
// Arrange (shared): setUp runs before each test
protected function setUp(): void
{
$this->engine = new PricingEngine();
}
public function test_applies_percentage_discount(): void
{
// ARRANGE — set up specific inputs for this test
$price = 100.00;
$discountPercent = 20.0;
// ACT — run the code being tested
$result = $this->engine->applyDiscount($price, $discountPercent);
// ASSERT — verify the outcome
$this->assertEquals(80.00, $result);
}
public function test_throws_when_discount_exceeds_100_percent(): void
{
// ARRANGE
$price = 50.00;
$invalidDiscount = 150.0;
// ASSERT (pre-arranged for exceptions — must be before ACT)
$this->expectException(\InvalidArgumentException::class);
// ACT — code throws, expectException captures it
$this->engine->applyDiscount($price, $invalidDiscount);
}
}
// FEATURE TEST with AAA
class OrderCreationTest extends \Tests\TestCase
{
use \Illuminate\Foundation\Testing\RefreshDatabase;
public function test_authenticated_user_can_create_order(): void
{
// ARRANGE — set up users, products, auth state
$user = User::factory()->create();
$product = Product::factory()->create(['price' => 29.99, 'stock' => 10]);
// ACT — make the HTTP request
$response = $this->actingAs($user, 'sanctum')
->postJson('/api/orders', [
'items' => [['product_id' => $product->id, 'quantity' => 2]],
]);
// ASSERT — check response and side effects
$response->assertStatus(201)
->assertJson(['status' => 'pending', 'total' => 59.98]);
$this->assertDatabaseHas('orders', ['user_id' => $user->id, 'total' => 59.98]);
$this->assertEquals(8, $product->fresh()->stock);
}
}
// GIVEN-WHEN-THEN (BDD variant — same structure)
class UserRegistrationTest extends \Tests\TestCase
{
public function test_new_user_can_register(): void
{
// GIVEN — a new visitor with unique email
$email = 'new_user_' . uniqid() . '@example.com';
// WHEN — they submit the registration form
$response = $this->postJson('/api/register', [
'name' => 'Alice',
'email' => $email,
'password' => 'SecurePass123!',
'password_confirmation' => 'SecurePass123!',
]);
// THEN — they are registered and authenticated
$response->assertStatus(201);
$this->assertDatabaseHas('users', ['email' => $email]);
}
}