Testing & TDD

Assertions Deep Dive: Mastering Test Verification

25 min Lesson 6 of 35

Understanding Assertions

Assertions are the heart of any test. They're the statements that verify whether your code behaves as expected. An assertion compares an actual value (produced by your code) against an expected value, and the test passes or fails based on this comparison. Mastering assertions is crucial for writing effective, reliable tests that catch bugs early and provide clear feedback when things go wrong.

Different testing frameworks provide different assertion APIs, but the underlying concepts remain the same: you're making a claim about how your code should behave, and the testing framework verifies that claim. In this lesson, we'll explore assertion patterns in both PHPUnit (for PHP) and Jest (for JavaScript), understanding when and how to use each type of assertion effectively.

The Assert-First Mindset:

Before writing any code, think about what you want to assert. What behavior are you testing? What should the output be? This "assertion-first" thinking helps you write more focused tests and clearer code.

PHPUnit Assertions

Basic Equality Assertions

PHPUnit provides multiple ways to test equality, each suited for different scenarios:

// Strict equality (=== comparison) $this->assertSame(5, $calculator->add(2, 3)); $this->assertSame('hello', $greeter->greet()); // Loose equality (== comparison) $this->assertEquals(5, '5'); // Passes - type coercion $this->assertEquals([1, 2], ['1', '2']); // Passes - loose comparison // Not equal $this->assertNotSame(5, 6); $this->assertNotEquals('hello', 'world');
Strict vs Loose Comparisons:

Use assertSame() for strict type checking. Use assertEquals() only when you intentionally want type coercion. In most cases, strict comparisons catch more bugs and make your tests more reliable.

Boolean and Null Assertions

// Boolean assertions $this->assertTrue($user->isActive()); $this->assertFalse($user->isDeleted()); // Null assertions $this->assertNull($user->getDeletedAt()); $this->assertNotNull($user->getCreatedAt());

Array and Collection Assertions

PHPUnit provides powerful assertions for working with arrays and collections:

$users = $repository->findAll(); // Check array size $this->assertCount(10, $users); // Check if array contains a value $this->assertContains('admin@example.com', $emails); // Check if array has a specific key $this->assertArrayHasKey('email', $user); // Check if array is empty $this->assertEmpty($errors); $this->assertNotEmpty($results); // Check if array is subset of another $expected = ['name' => 'John', 'active' => true]; $this->assertArraySubset($expected, $user);

String Assertions

// Check if string contains substring $this->assertStringContainsString('error', $message); // Check string start/end $this->assertStringStartsWith('https://', $url); $this->assertStringEndsWith('.com', $domain); // Regular expression matching $this->assertMatchesRegularExpression('/^[A-Z]\w+$/', $className); // JSON string assertions $this->assertJson($response->getContent()); $this->assertJsonStringEqualsJsonString( '{"name":"John"}', $response->getContent() );

Type Assertions

// Check variable types $this->assertIsInt($user->getId()); $this->assertIsString($user->getName()); $this->assertIsArray($user->getRoles()); $this->assertIsBool($user->isActive()); $this->assertIsFloat($product->getPrice()); $this->assertIsObject($user); // Check instance types $this->assertInstanceOf(User::class, $user); $this->assertInstanceOf(Collection::class, $users);

Exception Assertions

// Expect an exception to be thrown $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Email cannot be empty'); $this->expectExceptionCode(400); $user->setEmail(''); // This should throw // Alternative syntax for exception testing try { $user->setEmail(''); $this->fail('Expected exception was not thrown'); } catch (InvalidArgumentException $e) { $this->assertStringContainsString('Email', $e->getMessage()); }
Exception Testing Best Practices:

Always verify both the exception type and message when testing exceptions. This ensures you're catching the right error for the right reason, not just any exception.

File and Directory Assertions

// File existence $this->assertFileExists('/path/to/file.txt'); $this->assertFileDoesNotExist('/path/to/deleted.txt'); // Directory existence $this->assertDirectoryExists('/path/to/uploads'); $this->assertDirectoryIsReadable('/path/to/logs'); $this->assertDirectoryIsWritable('/path/to/cache'); // File content $this->assertFileEquals( '/path/to/expected.txt', '/path/to/actual.txt' ); $this->assertStringEqualsFile( '/path/to/expected.json', json_encode($data) );

Jest Matchers

Basic Matchers

Jest uses "matchers" instead of "assertions" - the terminology is different, but the concept is the same:

// Exact equality (Object.is) expect(2 + 2).toBe(4); expect(user.name).toBe('John'); // Deep equality (for objects and arrays) expect(user).toEqual({ name: 'John', email: 'john@example.com', active: true }); expect([1, 2, 3]).toEqual([1, 2, 3]); // Not equal expect(5).not.toBe(3); expect('hello').not.toEqual('world');
toBe vs toEqual:

Use toBe() for primitive values and toEqual() for objects/arrays. toBe() uses Object.is() which compares references for objects, while toEqual() does deep comparison of values.

Truthiness Matchers

// Specific values expect(null).toBeNull(); expect(undefined).toBeUndefined(); expect(value).toBeDefined(); // Truthy/Falsy expect(true).toBeTruthy(); expect(1).toBeTruthy(); expect('hello').toBeTruthy(); expect(false).toBeFalsy(); expect(0).toBeFalsy(); expect('').toBeFalsy(); expect(null).toBeFalsy(); expect(undefined).toBeFalsy();

Number Matchers

// Comparison matchers expect(10).toBeGreaterThan(5); expect(10).toBeGreaterThanOrEqual(10); expect(5).toBeLessThan(10); expect(5).toBeLessThanOrEqual(5); // Floating point equality expect(0.1 + 0.2).toBeCloseTo(0.3); // Handles floating point precision expect(Math.PI).toBeCloseTo(3.14159, 5); // 5 decimal places // Check if number is NaN expect(NaN).toBeNaN(); expect(0 / 0).toBeNaN();

String Matchers

// Substring matching expect('Hello World').toContain('World'); expect('team').not.toContain('I'); // Regular expression matching expect('user@example.com').toMatch(/^[\w\.\-]+@[\w\.\-]+\.\w+$/); expect('Hello123').toMatch(/\d+/); // Length expect('hello').toHaveLength(5); expect('').toHaveLength(0);

Array and Object Matchers

const users = [ { id: 1, name: 'John' }, { id: 2, name: 'Jane' }, { id: 3, name: 'Bob' } ]; // Array contains item expect(users).toContainEqual({ id: 1, name: 'John' }); expect([1, 2, 3]).toContain(2); // Array length expect(users).toHaveLength(3); expect([]).toHaveLength(0); // Object has property expect(users[0]).toHaveProperty('name'); expect(users[0]).toHaveProperty('name', 'John'); expect(users[0]).toHaveProperty(['name'], 'John'); // Object structure matching expect(users[0]).toMatchObject({ id: 1, name: 'John' });

Exception Matchers

// Function throws error expect(() => { throw new Error('Invalid email'); }).toThrow(); // Specific error message expect(() => { throw new Error('Invalid email'); }).toThrow('Invalid email'); // Error type expect(() => { throw new TypeError('Expected string'); }).toThrow(TypeError); // Regex matching error message expect(() => { throw new Error('Email cannot be empty'); }).toThrow(/email/i);
Common Mistake:

When testing exceptions, always wrap the code in an arrow function. Don't call the function directly: expect(myFunction).toThrow() is wrong! Use: expect(() => myFunction()).toThrow()

Promise Matchers

// Promise resolves test('promise resolves with user', async () => { await expect(fetchUser(1)).resolves.toEqual({ id: 1, name: 'John' }); }); // Promise rejects test('promise rejects with error', async () => { await expect(fetchUser(-1)).rejects.toThrow('Invalid user ID'); }); // Alternative syntax test('promise resolves', () => { return expect(fetchUser(1)).resolves.toBeDefined(); });

Custom Assertions and Matchers

Creating Custom PHPUnit Assertions

You can extend PHPUnit with custom assertion methods:

trait CustomAssertions { public function assertValidEmail(string $email, string $message = '') { $this->assertMatchesRegularExpression( '/^[\w\.\-]+@[\w\.\-]+\.\w+$/', $email, $message ?: "Failed asserting that '$email' is a valid email" ); } public function assertJsonStructure(array $structure, string $json) { $data = json_decode($json, true); foreach ($structure as $key) { $this->assertArrayHasKey( $key, $data, "JSON does not contain key: $key" ); } } public function assertDateFormat(string $date, string $format = 'Y-m-d') { $parsed = \DateTime::createFromFormat($format, $date); $this->assertNotFalse( $parsed, "Failed asserting that '$date' matches format '$format'" ); } } // Usage in test class UserTest extends TestCase { use CustomAssertions; public function test_user_has_valid_email() { $user = new User('john@example.com'); $this->assertValidEmail($user->getEmail()); } }

Creating Custom Jest Matchers

// Define custom matchers expect.extend({ toBeValidEmail(received) { const emailRegex = /^[\w\.\-]+@[\w\.\-]+\.\w+$/; const pass = emailRegex.test(received); return { pass, message: () => pass ? `expected ${received} not to be a valid email` : `expected ${received} to be a valid email` }; }, toHaveStatus(response, expectedStatus) { const pass = response.status === expectedStatus; return { pass, message: () => pass ? `expected status not to be ${expectedStatus}` : `expected status ${response.status} to be ${expectedStatus}` }; }, toBeWithinRange(received, floor, ceiling) { const pass = received >= floor && received <= ceiling; return { pass, message: () => pass ? `expected ${received} not to be within range ${floor} - ${ceiling}` : `expected ${received} to be within range ${floor} - ${ceiling}` }; } }); // Usage in tests test('validates email format', () => { expect('user@example.com').toBeValidEmail(); expect('invalid-email').not.toBeValidEmail(); }); test('response has correct status', async () => { const response = await fetch('/api/users'); expect(response).toHaveStatus(200); }); test('age is within valid range', () => { const user = { age: 25 }; expect(user.age).toBeWithinRange(18, 100); });
When to Create Custom Matchers:

Create custom matchers when you find yourself repeating the same complex assertion logic across multiple tests. Custom matchers improve readability and maintainability.

Assertion Best Practices

1. One Logical Assertion Per Test

Each test should verify one specific behavior:

// Good - focused test test('user registration creates new user', () => { const user = registerUser('john@example.com', 'password123'); expect(user).toBeDefined(); expect(user.email).toBe('john@example.com'); }); // Better - split into multiple tests test('user registration returns user object', () => { const user = registerUser('john@example.com', 'password123'); expect(user).toBeDefined(); }); test('registered user has correct email', () => { const user = registerUser('john@example.com', 'password123'); expect(user.email).toBe('john@example.com'); });

2. Use Descriptive Assertion Messages

// PHPUnit - optional message parameter $this->assertTrue( $user->isActive(), 'Expected user to be active after registration' ); $this->assertCount( 10, $results, 'Expected to find 10 users, but found ' . count($results) ); // Jest - custom messages via test descriptions test('user should be active after registration', () => { expect(user.isActive()).toBe(true); });

3. Test Both Success and Failure Cases

describe('Email validation', () => { test('accepts valid email formats', () => { expect(isValidEmail('user@example.com')).toBe(true); expect(isValidEmail('user.name@example.co.uk')).toBe(true); expect(isValidEmail('user+tag@example.com')).toBe(true); }); test('rejects invalid email formats', () => { expect(isValidEmail('invalid')).toBe(false); expect(isValidEmail('@example.com')).toBe(false); expect(isValidEmail('user@')).toBe(false); expect(isValidEmail('')).toBe(false); }); });

4. Use Specific Assertions

// Bad - too generic expect(user.roles.length > 0).toBe(true); // Good - specific assertion expect(user.roles).not.toHaveLength(0); // Better - even more specific expect(user.roles).toContain('admin'); // Bad - vague expect(!!user.deletedAt).toBe(false); // Good - clear intent expect(user.deletedAt).toBeNull();

5. Avoid Logic in Assertions

// Bad - logic in assertion expect(users.filter(u => u.active).length).toBe(5); // Good - prepare value first const activeUsers = users.filter(u => u.active); expect(activeUsers).toHaveLength(5); // Bad - calculation in assertion expect(cart.items.reduce((sum, item) => sum + item.price, 0)).toBe(100); // Good - calculate first const totalPrice = cart.items.reduce((sum, item) => sum + item.price, 0); expect(totalPrice).toBe(100);
Practice Exercise:
  1. Create a Validator class with methods for email, phone, and URL validation
  2. Write comprehensive tests using various assertion types:
    • Boolean assertions for validation results
    • String assertions for formatted outputs
    • Exception assertions for invalid inputs
  3. Create a custom assertion/matcher: toBeValidPhoneNumber()
  4. Write tests that verify both positive and negative cases
  5. Ensure each test has a clear, focused assertion

Common Assertion Antipatterns

Antipattern 1: Testing Framework Code

// Bad - testing JavaScript itself test('array push works', () => { const arr = [1, 2]; arr.push(3); expect(arr).toHaveLength(3); }); // Good - test your own code test('addItem adds product to cart', () => { cart.addItem(product); expect(cart.items).toContain(product); });

Antipattern 2: Meaningless Assertions

// Bad - always passes test('user exists', () => { const user = { name: 'John' }; expect(user).toBeDefined(); // This will always pass }); // Good - tests actual behavior test('finds user by ID', () => { const user = findUserById(1); expect(user).toMatchObject({ id: 1, name: 'John' }); });

Antipattern 3: Too Many Assertions

// Bad - testing too much test('user creation', () => { const user = createUser(data); expect(user.id).toBeDefined(); expect(user.name).toBe('John'); expect(user.email).toBe('john@example.com'); expect(user.isActive).toBe(true); expect(user.createdAt).toBeInstanceOf(Date); expect(user.roles).toContain('user'); // ... more assertions }); // Good - focused tests test('assigns ID to new user', () => { const user = createUser(data); expect(user.id).toBeDefined(); }); test('sets user properties from input', () => { const user = createUser({ name: 'John', email: 'john@example.com' }); expect(user).toMatchObject({ name: 'John', email: 'john@example.com' }); });

Summary

Mastering assertions is essential for writing effective tests. We've explored the comprehensive assertion APIs of both PHPUnit and Jest, learned how to create custom assertions for domain-specific needs, and understood best practices that make tests more maintainable and reliable.

Remember: good assertions are specific, focused, and descriptive. They test one thing well, provide clear feedback when they fail, and make your test suite a valuable documentation of how your code should behave. As you write more tests, you'll develop intuition for which assertions to use in different scenarios.