Lesson 07.3: Test Anatomy
Duration: 45 minutes | Difficulty: Beginner
Learning Objectives
By the end of this lesson, you will understand:
- The AAA (Arrange-Act-Assert) pattern
- How to name tests descriptively
- What makes a good assertion
- Common testing mistakes to avoid
The AAA Pattern
Every good test follows the Arrange-Act-Assert pattern:
describe('UserService', () => {
it('should hash password before storing', async () => {
// ARRANGE: Set up test data and dependencies
const userService = new UserService();
const email = 'user@example.com';
const password = 'PlainTextPassword123';
// ACT: Execute the function under test
const result = await userService.register(email, password);
// ASSERT: Verify the outcome
expect(result.password).not.toBe(password); // Not plain text
expect(result.password).toMatch(/^\$2[aby]\$/); // Bcrypt format
});
});
Arrange
Set up everything needed for the test:
- Create test data
- Initialize objects
- Set up mocks
- Configure state
// ARRANGE
const mockEmailService = { send: jest.fn() };
const authService = new AuthService(mockEmailService);
const testUser = {
email: 'test@example.com',
password: 'SecurePass123'
};
Act
Execute the code being tested:
- Call the function
- Make the API request
- Trigger the event
// ACT
const result = await authService.register(testUser.email, testUser.password);
Assert
Verify the expected outcome:
- Check return values
- Verify side effects
- Confirm state changes
// ASSERT
expect(result.id).toBeDefined();
expect(result.email).toBe(testUser.email);
expect(mockEmailService.send).toHaveBeenCalledWith({
to: testUser.email,
subject: 'Welcome!'
});
Test Naming
The "Should" Convention
// Pattern: should [expected behavior] when [condition]
it('should return 0 when cart is empty');
it('should throw error when password is too short');
it('should apply 20% discount when user is premium');
it('should redirect to dashboard when login is successful');
Good vs. Bad Names
// ❌ Bad: Vague, doesn't describe behavior
it('test calculateTotal');
it('works');
it('handles edge case');
// ✅ Good: Describes expected behavior
it('should return sum of all item prices');
it('should return 0 for empty cart');
it('should throw ValidationError for negative prices');
Reading Tests Like Documentation
When tests are well-named, describe and it read like specifications:
describe('ShoppingCart', () => {
describe('calculateTotal', () => {
it('should return sum of item prices');
it('should apply percentage discounts');
it('should apply fixed amount discounts');
it('should not allow total below zero');
});
describe('addItem', () => {
it('should add item to cart');
it('should increase quantity if item exists');
it('should throw if item is out of stock');
});
});
// Reads as:
// ShoppingCart
// calculateTotal
// ✓ should return sum of item prices
// ✓ should apply percentage discounts
// ...
Assertions
One Concept Per Test
// ❌ Bad: Testing multiple concepts
it('should register user', async () => {
const result = await authService.register('user@example.com', 'pass123');
expect(result.id).toBeDefined(); // Concept 1: User created
expect(result.email).toBe('user@example.com');
expect(result.password).not.toBe('pass123'); // Concept 2: Password hashed
expect(mockEmailService.send).toHaveBeenCalled(); // Concept 3: Email sent
});
// ✅ Good: Separate tests for each concept
it('should create user with generated ID', async () => {
const result = await authService.register('user@example.com', 'pass123');
expect(result.id).toBeDefined();
expect(result.email).toBe('user@example.com');
});
it('should hash password before storing', async () => {
const result = await authService.register('user@example.com', 'pass123');
expect(result.password).not.toBe('pass123');
expect(result.password).toMatch(/^\$2[aby]\$/);
});
it('should send welcome email after registration', async () => {
await authService.register('user@example.com', 'pass123');
expect(mockEmailService.send).toHaveBeenCalledWith({
to: 'user@example.com',
subject: expect.stringContaining('Welcome')
});
});
Meaningful Assertions
// ❌ Bad: Meaningless assertion
it('should work', () => {
const result = calculateTotal([{ price: 100 }]);
expect(result).toBeDefined(); // Just checks it exists
});
// ✅ Good: Verifies actual behavior
it('should return sum of prices', () => {
const result = calculateTotal([{ price: 100 }, { price: 50 }]);
expect(result).toBe(150); // Verifies correct calculation
});
Assertion Types
// Equality
expect(value).toBe(5); // Strict equality (===)
expect(value).toEqual({ a: 1 }); // Deep equality for objects
// Truthiness
expect(value).toBeTruthy();
expect(value).toBeFalsy();
expect(value).toBeNull();
expect(value).toBeDefined();
expect(value).toBeUndefined();
// Numbers
expect(value).toBeGreaterThan(3);
expect(value).toBeLessThanOrEqual(10);
expect(value).toBeCloseTo(0.3, 5); // Float comparison
// Strings
expect(string).toMatch(/pattern/);
expect(string).toContain('substring');
// Arrays
expect(array).toContain(item);
expect(array).toHaveLength(3);
// Exceptions
expect(() => badFunction()).toThrow();
expect(() => badFunction()).toThrow('Error message');
expect(() => badFunction()).toThrow(TypeError);
// Async
await expect(asyncFn()).resolves.toBe(value);
await expect(asyncFn()).rejects.toThrow('Error');
Test Independence
Tests Should Not Depend on Each Other
// ❌ Bad: Tests depend on shared state
let user;
it('should create user', () => {
user = createUser('test@example.com');
expect(user.id).toBeDefined();
});
it('should update user email', () => {
updateUser(user.id, { email: 'new@example.com' }); // Depends on previous test!
expect(user.email).toBe('new@example.com');
});
// ✅ Good: Each test is independent
it('should create user', () => {
const user = createUser('test@example.com');
expect(user.id).toBeDefined();
});
it('should update user email', () => {
const user = createUser('test@example.com'); // Creates its own user
updateUser(user.id, { email: 'new@example.com' });
const updated = getUser(user.id);
expect(updated.email).toBe('new@example.com');
});
Use Setup and Teardown
describe('UserService', () => {
let db;
let userService;
// Run before each test
beforeEach(async () => {
db = await createTestDatabase();
userService = new UserService(db);
});
// Run after each test
afterEach(async () => {
await db.truncate();
await db.close();
});
it('should create user', async () => {
const user = await userService.create('test@example.com');
expect(user.id).toBeDefined();
});
it('should find user by email', async () => {
await userService.create('test@example.com');
const user = await userService.findByEmail('test@example.com');
expect(user).toBeDefined();
});
});
Testing Edge Cases
Don't just test the happy path!
describe('validateEmail', () => {
// Happy path
it('should accept valid email', () => {
expect(validateEmail('user@example.com')).toBe(true);
});
// Edge cases
it('should reject email without @', () => {
expect(() => validateEmail('userexample.com')).toThrow();
});
it('should reject email without domain', () => {
expect(() => validateEmail('user@')).toThrow();
});
it('should reject empty string', () => {
expect(() => validateEmail('')).toThrow('Email required');
});
it('should reject null', () => {
expect(() => validateEmail(null)).toThrow('Email required');
});
it('should reject undefined', () => {
expect(() => validateEmail(undefined)).toThrow('Email required');
});
it('should trim whitespace', () => {
expect(validateEmail(' user@example.com ')).toBe(true);
});
// Boundary cases
it('should accept email at max length', () => {
const longEmail = 'a'.repeat(64) + '@example.com';
expect(validateEmail(longEmail)).toBe(true);
});
it('should reject email over max length', () => {
const tooLongEmail = 'a'.repeat(65) + '@example.com';
expect(() => validateEmail(tooLongEmail)).toThrow();
});
});
Common Mistakes
1. Testing Implementation, Not Behavior
// ❌ Bad: Tests implementation details
it('should call hashPassword with bcrypt', () => {
const spy = jest.spyOn(bcrypt, 'hash');
authService.register('user@example.com', 'password');
expect(spy).toHaveBeenCalledWith('password', 10);
});
// ✅ Good: Tests behavior
it('should not store plain text password', async () => {
const result = await authService.register('user@example.com', 'password');
expect(result.password).not.toBe('password');
});
2. Flaky Tests
// ❌ Bad: Depends on timing
it('should debounce search', async () => {
searchInput.type('hello');
await sleep(500); // Arbitrary wait
expect(results).toHaveLength(5);
});
// ✅ Good: Explicit wait for condition
it('should debounce search', async () => {
searchInput.type('hello');
await waitFor(() => expect(results).toHaveLength(5));
});
3. Test Pollution
// ❌ Bad: Modifies global state
beforeAll(() => {
global.DEBUG = true; // Affects all tests!
});
// ✅ Good: Restore state
let originalDebug;
beforeEach(() => {
originalDebug = global.DEBUG;
global.DEBUG = true;
});
afterEach(() => {
global.DEBUG = originalDebug;
});
Key Takeaways
- Follow AAA — Arrange, Act, Assert structure
- Name descriptively — "should [behavior] when [condition]"
- One concept per test — easier to debug failures
- Tests must be independent — no shared state between tests
- Test edge cases — empty, null, boundaries, errors
Next Lesson
Now let's see how SpecWeave integrates testing into the development workflow.