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:
- Create a
Validator class with methods for email, phone, and URL validation
- Write comprehensive tests using various assertion types:
- Boolean assertions for validation results
- String assertions for formatted outputs
- Exception assertions for invalid inputs
- Create a custom assertion/matcher:
toBeValidPhoneNumber()
- Write tests that verify both positive and negative cases
- 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.