| name | test-craft |
| description | Apply BDD testing principles: test pyramid, behavior-driven tests, proper test doubles, and maintainable test suites |
| user-invocable | false |
| context | fork |
| agent | qa-engineer |
| allowed-tools | Read, Grep, Glob, Edit, Write, Bash |
Test Craft Skill
You are applying craftsmanship principles to testing. Follow these guidelines rigorously.
BDD Workflow
Behavior-Driven Testing
1. DESCRIBE โ Define the behavior in Given-When-Then terms
2. IMPLEMENT โ Write code + tests together
3. VERIFY โ All tests pass, behaviors documented
4. REFACTOR โ Improve while tests stay green
Start from the Outside
describe('User Registration', () => {
it('should send welcome email after successful registration', async () => {
const result = await registerUser({
email: 'new@example.com',
password: 'SecurePass123!',
})
expect(result.ok).toBe(true)
expect(emailService.sentEmails).toContainEqual(
expect.objectContaining({
to: 'new@example.com',
subject: 'Welcome!',
})
)
})
})
describe('validatePassword', () => {
it('should reject passwords shorter than 8 characters', () => {
const result = validatePassword('short')
expect(result.ok).toBe(false)
expect(result.error.code).toBe('PASSWORD_TOO_SHORT')
})
})
Test Structure
Arrange-Act-Assert (AAA)
describe('ShoppingCart', () => {
describe('when adding an item', () => {
it('should increase total by item price', () => {
const cart = new ShoppingCart()
const item = createTestItem({ price: 29.99 })
cart.add(item)
expect(cart.total).toBe(29.99)
})
})
})
Given-When-Then (BDD)
describe('User Authentication', () => {
describe('given a registered user', () => {
const user = createTestUser({ email: 'user@test.com' })
describe('when logging in with correct credentials', () => {
it('then should return an access token', async () => {
const result = await login({
email: user.email,
password: 'correctPassword',
})
expect(result.ok).toBe(true)
expect(result.value.accessToken).toBeDefined()
})
})
describe('when logging in with wrong password', () => {
it('then should return invalid credentials error', async () => {
const result = await login({
email: user.email,
password: 'wrongPassword',
})
expect(result.ok).toBe(false)
expect(result.error.code).toBe('INVALID_CREDENTIALS')
})
})
})
})
Descriptive Test Names
it('should work', () => {})
it('test add', () => {})
it('handles error', () => {})
it('should calculate discount when cart total exceeds $100', () => {})
it('should reject expired credit cards with clear error message', () => {})
it('should retry failed API calls up to 3 times', () => {})
Test Pyramid
Unit Tests (Base - Many)
Fast, isolated, test single units:
describe('calculateDiscount', () => {
it('should apply 10% discount for orders over $100', () => {
const discount = calculateDiscount(150)
expect(discount).toBe(15)
})
it('should not apply discount for orders under $100', () => {
const discount = calculateDiscount(50)
expect(discount).toBe(0)
})
it('should handle edge case of exactly $100', () => {
const discount = calculateDiscount(100)
expect(discount).toBe(0)
})
})
Integration Tests (Middle - Some)
Test component interactions:
describe('UserService', () => {
let userService: UserService
let userRepository: InMemoryUserRepository
beforeEach(() => {
userRepository = new InMemoryUserRepository()
userService = new UserService(userRepository)
})
it('should persist user and return with generated id', async () => {
const result = await userService.createUser({
email: 'test@example.com',
name: 'Test User',
})
expect(result.ok).toBe(true)
expect(result.value.id).toBeDefined()
const found = await userRepository.findById(result.value.id)
expect(found).toEqual(result.value)
})
})
E2E Tests (Top - Few)
Test critical user journeys:
describe('Checkout Flow', () => {
it('should complete purchase with valid payment', async () => {
await page.goto('/products')
await page.click('[data-testid="add-to-cart-1"]')
await page.click('[data-testid="go-to-checkout"]')
await page.fill('[name="cardNumber"]', '4242424242424242')
await page.fill('[name="expiry"]', '12/25')
await page.fill('[name="cvc"]', '123')
await page.click('[data-testid="pay-now"]')
await expect(page.locator('.order-confirmation')).toBeVisible()
})
})
Test Doubles
When to Use Each
| Double | Purpose | Example |
|---|
| Stub | Provide canned answers | stub.returns({ id: '123' }) |
| Mock | Verify interactions | expect(mock).toHaveBeenCalledWith(...) |
| Spy | Record calls, keep behavior | vi.spyOn(service, 'method') |
| Fake | Working implementation | InMemoryUserRepository |
Prefer Fakes Over Mocks
const mockRepo = {
findById: vi.fn().mockResolvedValue(user),
save: vi.fn().mockResolvedValue(undefined),
delete: vi.fn().mockResolvedValue(undefined),
}
class InMemoryUserRepository implements UserRepository {
private users = new Map<string, User>()
async findById(id: string): Promise<User | null> {
return this.users.get(id) ?? null
}
async save(user: User): Promise<void> {
this.users.set(user.id, user)
}
async delete(id: string): Promise<void> {
this.users.delete(id)
}
}
Mock External Services
import { setupServer } from 'msw/node'
import { http, HttpResponse } from 'msw'
const server = setupServer(
http.get('/api/users/:id', ({ params }) => {
return HttpResponse.json({
id: params.id,
name: 'Test User',
})
}),
http.post('/api/users', async ({ request }) => {
const body = await request.json()
return HttpResponse.json({ id: 'new-id', ...body }, { status: 201 })
})
)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
Test Factories
Build Test Data Consistently
import { faker } from '@faker-js/faker'
type UserOverrides = Partial<User>
export function createTestUser(overrides: UserOverrides = {}): User {
return {
id: faker.string.uuid(),
email: faker.internet.email(),
name: faker.person.fullName(),
createdAt: new Date(),
...overrides,
}
}
const user = createTestUser({ email: 'specific@test.com' })
const admin = createTestUser({ role: 'admin' })
Builder Pattern for Complex Objects
class OrderBuilder {
private order: Partial<Order> = {}
withCustomer(customer: Customer): this {
this.order.customer = customer
return this
}
withItems(items: OrderItem[]): this {
this.order.items = items
return this
}
withDiscount(discount: number): this {
this.order.discount = discount
return this
}
build(): Order {
return {
id: faker.string.uuid(),
customer: this.order.customer ?? createTestCustomer(),
items: this.order.items ?? [createTestOrderItem()],
discount: this.order.discount ?? 0,
createdAt: new Date(),
}
}
}
const order = new OrderBuilder()
.withCustomer(vipCustomer)
.withDiscount(20)
.build()
Edge Cases & Error Paths
Always Test Error Scenarios
describe('UserService.createUser', () => {
it('should return error when email already exists', async () => {
const existingUser = createTestUser({ email: 'taken@test.com' })
await userRepository.save(existingUser)
const result = await userService.createUser({
email: 'taken@test.com',
name: 'New User',
})
expect(result.ok).toBe(false)
expect(result.error.code).toBe('EMAIL_ALREADY_EXISTS')
})
it('should return error when email format is invalid', async () => {
const result = await userService.createUser({
email: 'not-an-email',
name: 'Test',
})
expect(result.ok).toBe(false)
expect(result.error.code).toBe('INVALID_EMAIL_FORMAT')
})
})
Boundary Testing
describe('Pagination', () => {
it('should handle empty results', async () => {
const result = await getUsers({ page: 1, limit: 10 })
expect(result.items).toEqual([])
expect(result.totalPages).toBe(0)
})
it('should handle last page with fewer items', async () => {
await seedUsers(25)
const result = await getUsers({ page: 3, limit: 10 })
expect(result.items).toHaveLength(5)
expect(result.hasNextPage).toBe(false)
})
it('should handle page beyond total pages', async () => {
await seedUsers(10)
const result = await getUsers({ page: 100, limit: 10 })
expect(result.items).toEqual([])
})
})
Test Organization
File Structure
src/
โโโ domain/
โ โโโ user/
โ โโโ user.ts
โ โโโ user.test.ts # Unit tests next to source
โโโ application/
โ โโโ user/
โ โโโ create-user.ts
โ โโโ create-user.test.ts
โโโ infrastructure/
โโโ persistence/
โโโ postgres-user-repo.ts
โโโ postgres-user-repo.integration.test.ts
test/
โโโ e2e/
โ โโโ checkout.e2e.test.ts # E2E tests separate
โโโ factories/
โ โโโ user.factory.ts # Shared test factories
โโโ fixtures/
โ โโโ users.json # Test data fixtures
โโโ setup.ts # Test configuration
Test Configuration
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.test.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
exclude: ['**/*.test.ts', 'test/**'],
},
setupFiles: ['./test/setup.ts'],
},
})
Anti-Patterns to Avoid
Don't Test Implementation Details
it('should set isLoading to true', () => {
const { result } = renderHook(() => useUsers())
expect(result.current.isLoading).toBe(true)
})
it('should show loading indicator while fetching', () => {
render(<UserList />)
expect(screen.getByRole('progressbar')).toBeInTheDocument()
})
Don't Use Arbitrary Waits
await new Promise((r) => setTimeout(r, 1000))
expect(element).toBeVisible()
await waitFor(() => {
expect(element).toBeVisible()
})
Don't Share Mutable State
let user: User
beforeAll(() => {
user = createTestUser()
})
it('test 1', () => {
user.name = 'Modified'
})
let user: User
beforeEach(() => {
user = createTestUser()
})
When Reviewing Test Code
Check for:
- Test names describe behavior clearly
- AAA/GWT structure is followed
- No implementation details tested
- Error scenarios covered
- Edge cases handled
- No flaky tests (arbitrary waits, shared state)
- Appropriate test level (unit vs integration vs e2e)
- Test doubles used appropriately