16 KiB
Test Patterns Reference
Comprehensive test patterns for TypeScript/Bun and Rust.
TypeScript/Bun Patterns
Basic Test Structure
import { describe, test, expect } from 'bun:test'
describe('Module or Feature Name', () => {
test('describes specific behavior', () => {
// Arrange
const input = createTestInput()
// Act
const result = functionUnderTest(input)
// Assert
expect(result).toBe(expected)
})
})
Discriminated Unions for Test Scenarios
Use discriminated unions to make test scenarios type-safe:
type TestScenario =
| { type: 'success'; input: ValidInput; expected: Output }
| { type: 'error'; input: InvalidInput; expectedError: ErrorCode }
| { type: 'edge-case'; input: EdgeInput; expected: Output }
test.each<TestScenario>([
{
type: 'success',
input: { value: 100 },
expected: { result: 100 },
},
{
type: 'error',
input: { value: -1 },
expectedError: 'NEGATIVE_VALUE',
},
{
type: 'edge-case',
input: { value: 0 },
expected: { result: 0 },
},
])('handles $type scenario', async (scenario) => {
const result = await processValue(scenario.input)
if (scenario.type === 'success' || scenario.type === 'edge-case') {
expect(result).toEqual(scenario.expected)
} else {
expect(result.error).toBe(scenario.expectedError)
}
})
Type-Safe Test Builders
Create fluent builders for complex test data:
class UserBuilder {
private data: Partial<User> = {
id: 'test-id',
email: 'test@example.com',
role: 'user',
createdAt: new Date('2024-01-01'),
}
withId(id: string): this {
this.data.id = id
return this
}
withEmail(email: string): this {
this.data.email = email
return this
}
withRole(role: UserRole): this {
this.data.role = role
return this
}
asAdmin(): this {
return this.withRole('admin')
}
build(): User {
return this.data as User
}
}
// Usage
const adminUser = new UserBuilder()
.withEmail('admin@example.com')
.asAdmin()
.build()
Generic builder for flexibility:
class Builder<T> {
constructor(private defaults: T) {}
with<K extends keyof T>(key: K, value: T[K]): this {
this.defaults = { ...this.defaults, [key]: value }
return this
}
build(): T {
return { ...this.defaults }
}
}
// Usage
const userBuilder = new Builder<User>({
id: 'test-id',
email: 'test@example.com',
role: 'user',
})
const admin = userBuilder.with('role', 'admin').build()
Const Assertions for Test Data
Type-safe test data with const assertions:
const validInputs = [
{ input: 'hello', expected: 'HELLO' },
{ input: 'world', expected: 'WORLD' },
{ input: '', expected: '' },
] as const
test.each(validInputs)(
'transforms $input to $expected',
({ input, expected }) => {
expect(transform(input)).toBe(expected)
}
)
Async Testing Patterns
Promise rejection:
test('rejects with error for invalid input', async () => {
const promise = fetchUser('invalid-id')
await expect(promise).rejects.toThrow(UserNotFoundError)
await expect(promise).rejects.toThrow('User not found')
})
Async/await with error handling:
test('handles async errors gracefully', async () => {
const result = await processData('invalid').catch(err => ({
error: err.message,
}))
expect(result.error).toBe('Invalid data')
})
Timeout handling:
test('times out slow operations', async () => {
const promise = slowOperation()
await expect(
Promise.race([
promise,
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), 100)
),
])
).rejects.toThrow('Timeout')
})
Mocking with Bun
Module mocking:
import { mock } from 'bun:test'
// Mock entire module
mock.module('./database', () => ({
query: mock(() => Promise.resolve({ rows: [] })),
connect: mock(() => Promise.resolve()),
}))
// Use in test
test('handles database errors', async () => {
const { query } = await import('./database')
query.mockImplementationOnce(() => Promise.reject(new Error('DB Error')))
const result = await fetchUsers()
expect(result.error).toBe('DB Error')
})
Function mocking:
const mockFetch = mock(async (url: string) => ({
ok: true,
json: async () => ({ data: 'test' }),
}))
test('fetches data successfully', async () => {
const result = await fetchData('https://api.example.com', mockFetch)
expect(mockFetch).toHaveBeenCalledWith('https://api.example.com')
expect(result.data).toBe('test')
})
Snapshot Testing
Simple snapshots:
test('serializes user correctly', () => {
const user = new UserBuilder().build()
expect(JSON.stringify(user, null, 2)).toMatchSnapshot()
})
Inline snapshots:
test('formats error message', () => {
const error = new ValidationError('Invalid email')
expect(error.message).toMatchInlineSnapshot(`"Invalid email"`)
})
Parameterized Tests
Basic parameterization:
test.each([
[1, 1],
[2, 4],
[3, 9],
[4, 16],
])('square(%i) returns %i', (input, expected) => {
expect(square(input)).toBe(expected)
})
Object-based parameterization:
test.each([
{ input: 5, expected: 25, description: 'positive number' },
{ input: -3, expected: 9, description: 'negative number' },
{ input: 0, expected: 0, description: 'zero' },
])('square($input) for $description', ({ input, expected }) => {
expect(square(input)).toBe(expected)
})
Focused Testing
Run specific tests:
// Only run this test
test.only('current feature under development', () => {
// Fast feedback during active development
})
// Skip slow tests during TDD
test.skip('slow integration test', () => {
// Run in CI but not during rapid TDD cycles
})
// Mark test as work in progress
test.todo('implement rate limiting')
Parallel Test Execution
Run independent tests in parallel:
describe.concurrent('Independent Operations', () => {
test('operation 1', async () => {
const result = await independentOp1()
expect(result).toBeDefined()
})
test('operation 2', async () => {
const result = await independentOp2()
expect(result).toBeDefined()
})
test('operation 3', async () => {
const result = await independentOp3()
expect(result).toBeDefined()
})
})
Error Testing Patterns
Exception testing:
test('throws error for invalid input', () => {
expect(() => processData(null)).toThrow(ValidationError)
expect(() => processData(null)).toThrow('Input cannot be null')
})
Error result testing:
test('returns error result for invalid input', () => {
const result = processData(null)
expect(result.type).toBe('error')
if (result.type === 'error') {
expect(result.code).toBe('INVALID_INPUT')
expect(result.message).toContain('null')
}
})
Rust Patterns
Basic Test Structure
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_basic_functionality() {
// Arrange
let input = setup_test_input();
// Act
let result = function_under_test(input);
// Assert
assert_eq!(result, expected);
}
}
Property-Based Testing
Using proptest:
use proptest::prelude::*;
proptest! {
#[test]
fn password_hash_is_deterministic(password in "[a-zA-Z0-9]{8,32}") {
let hash1 = hash_password(&password);
let hash2 = hash_password(&password);
prop_assert_eq!(hash1, hash2);
}
#[test]
fn email_validation_never_panics(email in ".*") {
let result = validate_email(&email);
// Should always return Ok or Err, never panic
prop_assert!(result.is_ok() || result.is_err());
}
#[test]
fn parse_then_serialize_is_identity(value in 0..1000) {
let serialized = serialize_value(value);
let parsed = parse_value(&serialized).unwrap();
prop_assert_eq!(value, parsed);
}
}
Test Builders
Rust builder pattern:
#[derive(Default)]
struct UserBuilder {
id: Option<String>,
email: Option<String>,
role: Option<Role>,
}
impl UserBuilder {
fn new() -> Self {
Self::default()
}
fn with_id(mut self, id: impl Into<String>) -> Self {
self.id = Some(id.into());
self
}
fn with_email(mut self, email: impl Into<String>) -> Self {
self.email = Some(email.into());
self
}
fn with_role(mut self, role: Role) -> Self {
self.role = Some(role);
self
}
fn as_admin(self) -> Self {
self.with_role(Role::Admin)
}
fn build(self) -> User {
User {
id: self.id.unwrap_or_else(|| "test-id".to_string()),
email: self.email.unwrap_or_else(|| "test@example.com".to_string()),
role: self.role.unwrap_or(Role::User),
}
}
}
// Usage
#[test]
fn test_admin_permissions() {
let admin = UserBuilder::new()
.with_email("admin@example.com")
.as_admin()
.build();
assert!(has_admin_access(&admin));
}
Async Testing
Using tokio::test:
#[tokio::test]
async fn authenticates_user_async() {
let credentials = Credentials {
email: "user@example.com".to_string(),
password: "password".to_string(),
};
let result = authenticate_async(&credentials).await;
assert!(result.is_ok());
}
#[tokio::test]
#[should_panic(expected = "timeout")]
async fn times_out_slow_operations() {
tokio::time::timeout(
Duration::from_millis(100),
very_slow_operation()
).await.expect("timeout");
}
Result Testing
Testing Result types:
#[test]
fn returns_error_for_invalid_input() {
let result = process_data(None);
assert!(result.is_err());
assert!(matches!(result, Err(ProcessError::InvalidInput)));
}
#[test]
fn returns_success_for_valid_input() {
let result = process_data(Some("valid"));
assert!(result.is_ok());
let value = result.unwrap();
assert_eq!(value, "processed");
}
Using assert_matches! macro:
#[test]
fn authenticates_with_valid_credentials() {
let result = authenticate(&valid_creds);
assert!(matches!(result, Ok(AuthResult::Success { .. })));
}
#[test]
fn rejects_invalid_credentials() {
let result = authenticate(&invalid_creds);
assert!(matches!(result, Err(AuthError::InvalidCredentials)));
}
Documentation Tests
Executable documentation:
/// Authenticates a user with credentials.
///
/// # Examples
///
/// ```
/// use auth::{authenticate, Credentials};
///
/// let creds = Credentials {
/// email: "user@example.com".to_string(),
/// password: "password".to_string(),
/// };
///
/// let result = authenticate(&creds);
/// assert!(result.is_ok());
/// ```
///
/// # Errors
///
/// Returns `AuthError::InvalidCredentials` if credentials are invalid:
///
/// ```
/// use auth::{authenticate, Credentials, AuthError};
///
/// let bad_creds = Credentials {
/// email: "wrong@example.com".to_string(),
/// password: "wrong".to_string(),
/// };
///
/// let result = authenticate(&bad_creds);
/// assert!(matches!(result, Err(AuthError::InvalidCredentials)));
/// ```
pub fn authenticate(credentials: &Credentials) -> Result<AuthResult, AuthError> {
// Implementation
}
Snapshot Testing
Using insta:
use insta::assert_snapshot;
#[test]
fn serializes_user_correctly() {
let user = User {
id: "test-id".to_string(),
email: "test@example.com".to_string(),
role: Role::Admin,
};
let serialized = serde_json::to_string_pretty(&user).unwrap();
assert_snapshot!(serialized);
}
Parameterized Tests
Manual parameterization:
#[test]
fn test_square() {
let test_cases = vec![
(5, 25),
(-3, 9),
(0, 0),
(10, 100),
];
for (input, expected) in test_cases {
assert_eq!(square(input), expected, "Failed for input {}", input);
}
}
Using rstest:
use rstest::rstest;
#[rstest]
#[case(5, 25)]
#[case(-3, 9)]
#[case(0, 0)]
#[case(10, 100)]
fn test_square(#[case] input: i32, #[case] expected: i32) {
assert_eq!(square(input), expected);
}
Mock Objects
Using mockall:
#[cfg(test)]
mod tests {
use super::*;
use mockall::predicate::*;
use mockall::mock;
mock! {
Database {}
impl Database {
fn query(&self, sql: &str) -> Result<Vec<Row>, DbError>;
fn execute(&self, sql: &str) -> Result<u64, DbError>;
}
}
#[test]
fn handles_empty_database() {
let mut mock_db = MockDatabase::new();
mock_db
.expect_query()
.with(eq("SELECT * FROM users"))
.returning(|_| Ok(vec![]));
let users = find_all_users(&mock_db);
assert_eq!(users.len(), 0);
}
#[test]
fn handles_database_error() {
let mut mock_db = MockDatabase::new();
mock_db
.expect_query()
.returning(|_| Err(DbError::ConnectionLost));
let result = find_all_users(&mock_db);
assert!(result.is_err());
}
}
Error Testing
Custom error types:
#[test]
fn returns_custom_error() {
let result = process_value(-1);
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.to_string(), "Value cannot be negative");
}
#[test]
fn error_contains_context() {
let result = parse_config("invalid");
match result {
Err(ConfigError::ParseError { line, message }) => {
assert_eq!(line, 1);
assert!(message.contains("invalid"));
}
_ => panic!("Expected ParseError"),
}
}
Integration Test Structure
Separate integration tests in tests/ directory:
// tests/integration/user_api.rs
use my_crate::*;
#[tokio::test]
async fn test_user_registration_flow() {
// Setup test database
let db = setup_test_db().await;
// Create user
let user = register_user(&db, "test@example.com", "password").await.unwrap();
// Verify user created
let found = find_user(&db, user.id).await.unwrap();
assert_eq!(found.email, "test@example.com");
// Cleanup
cleanup_test_db(db).await;
}
Common Test Smells and Solutions
Test Smell: Setup Longer Than Test
❌ Bad:
test('processes order', () => {
const user = { id: '1', email: 'test@example.com', role: 'user', /* 10 more fields */ }
const product = { id: 'p1', name: 'Widget', price: 100, /* 8 more fields */ }
const cart = { items: [{ product, quantity: 2 }], /* 5 more fields */ }
const payment = { method: 'card', /* 6 more fields */ }
const result = processOrder(user, cart, payment)
expect(result.total).toBe(200)
})
✓ Good:
test('processes order', () => {
const order = new OrderBuilder().withQuantity(2).withPrice(100).build()
const result = processOrder(order)
expect(result.total).toBe(200)
})
Test Smell: Multiple Unrelated Assertions
❌ Bad:
test('user management', () => {
expect(createUser('test@example.com')).toBeDefined()
expect(findUser('1')).toEqual({ id: '1' })
expect(deleteUser('1')).toBe(true)
})
✓ Good:
test('creates user with valid email', () => {
expect(createUser('test@example.com')).toBeDefined()
})
test('finds user by id', () => {
expect(findUser('1')).toEqual({ id: '1' })
})
test('deletes user successfully', () => {
expect(deleteUser('1')).toBe(true)
})
Test Smell: Testing Implementation Details
❌ Bad:
test('caches results internally', () => {
const service = new UserService()
service.fetchUser('1')
expect(service._cache.has('1')).toBe(true) // Testing private implementation
})
✓ Good:
test('returns cached user on second fetch', async () => {
const service = new UserService()
const spy = mock.fn()
await service.fetchUser('1', spy)
await service.fetchUser('1', spy)
expect(spy).toHaveBeenCalledTimes(1) // Testing observable behavior
})