CROP Microservices - Comprehensive Testing Guide
1. Overview(overview) 2. Testing Strategy(testing-strategy) 3. Test Types(test-types) 4. Running Tests(running-tests) 5. Unit Testing(unit-testing) 6....
CROP Microservices - Comprehensive Testing Guide
Table of Contents
- Overview
- Testing Strategy
- Test Types
- Running Tests
- Unit Testing
- Integration Testing
- E2E Testing
- Testing Best Practices
- CI/CD Integration
- Troubleshooting
Overview
This document provides comprehensive guidance on testing the CROP microservices monorepo. Our testing strategy emphasizes:
- Fast feedback through unit tests
- Confidence through integration tests
- Reliability through E2E tests
- Automation through CI/CD
Test Coverage Goals
- Unit Tests: 80%+ coverage for shared packages
- Integration Tests: All API endpoints
- E2E Tests: Critical user flows
Testing Tools
- Test Runner: Bun test (built-in, fast)
- Assertions: expect() from Bun
- Mocking: @crop/shared-testing utilities
- Type Safety: TypeScript + Zod schemas
Testing Strategy
Test Pyramid
/\
/ \ E2E Tests (Few, Slow, High Confidence)
/----\
/ \ Integration Tests (Some, Medium Speed)
/--------\
/ \ Unit Tests (Many, Fast, Focused)
/__________\What to Test
DO Test:
- Business logic and algorithms
- API contract compliance (schemas)
- Error handling and edge cases
- Integration points between services
- Critical user workflows
DON'T Test:
- Third-party library internals
- Framework code (Hono, MongoDB, etc.)
- Simple getters/setters
- Configuration files
Test Types
1. Unit Tests
Purpose: Test isolated units of code (functions, classes)
Characteristics:
- Fast execution (< 1ms per test)
- No external dependencies
- Use mocks/stubs for dependencies
- Focus on single responsibility
Location: src/__tests__/**/*.test.ts
Example: ```typescript describe('normalizePartNumber', () => { test('should remove special characters', () => { expect(normalizePartNumber('ABC-123')).toBe('abc123'); }); }); ```
2. Integration Tests
Purpose: Test interactions between components/services
Characteristics:
- Medium execution speed (10-100ms per test)
- May use test databases or mocked services
- Test API endpoints end-to-end
- Validate request/response contracts
Location: src/__tests__/integration/**/*.test.ts
Example: ```typescript describe('GET /api/parts', () => { test('should return paginated parts', async () => { const response = await tester.get('/api/parts?page=1&limit=20'); expect(response.status).toBe(200); expect(response.json.success).toBe(true); expect(response.json.data.items).toBeArray(); }); }); ```
3. E2E Tests
Purpose: Test complete user workflows
Characteristics:
- Slow execution (seconds per test)
- Use real services (in Docker)
- Test critical paths only
- Validate business outcomes
Location: tests/e2e/**/*.test.ts
Example: ```typescript describe('Part Search Flow', () => { test('user can search and view part details', async () => { // 1. Search for part const searchResponse = await searchPart('ABC123'); expect(searchResponse.results).not.toBeEmpty();
// 2. Get part details
const partId = searchResponse.results[0].id;
const detailsResponse = await getPartDetails(partId);
expect(detailsResponse.partNumber).toBe('ABC123');
// 3. Check fitment
const fitmentResponse = await getPartFitment(partId);
expect(fitmentResponse.equipment).toBeArray();}); }); ```
Running Tests
All Tests (Monorepo)
```bash
Run all tests across all packages and services
bun test
Run with watch mode
bun test --watch
Run with coverage
bun test --coverage
Run specific file
bun test src/tests/example.test.ts ```
Per-Package Tests
```bash
Run tests for specific package
cd packages/shared-types bun test
Run tests for specific service
cd services/catalog bun test ```
Docker Tests
```bash
Start infrastructure
make dev
Run integration tests (requires running services)
bun test --grep "integration"
Clean up
make down ```
Test Scripts
```bash
Run all tests with summary
bun run test:all
Run tests in CI mode
bun test --ci
Run tests with specific reporter
bun test --reporter=junit ```
Unit Testing
Using AppTester
The @crop/shared-testing package provides AppTester for testing Hono applications without starting a server:
```typescript import { AppTester } from '@crop/shared-testing'; import { createApp } from '../app';
describe('Catalog API', () => { const tester = new AppTester(createApp());
test('GET /health returns ok', async () => { const response = await tester.get('/health'); expect(response.status).toBe(200); expect(response.json).toEqual({ status: 'ok', service: 'catalog' }); });
test('POST /api/parts creates part', async () => { const response = await tester.post('/api/parts', { body: { partNumber: 'ABC123', manufacturer: 'ACME', }, }); expect(response.status).toBe(201); expect(response.json.success).toBe(true); }); }); ```
Using MockServiceClient
Mock inter-service calls without real HTTP requests:
```typescript import { MockServiceClient } from '@crop/shared-testing';
describe('Search Integration', () => { test('should call search service', async () => { const mockClient = new MockServiceClient('http://localhost:3001');
// Setup mock response
mockClient.mockGet('/api/search', {
success: true,
data: {
results: [{ id: '1', partNumber: 'ABC123' }],
},
});
// Use mock in your code
const results = await mockClient.get('/api/search', {
query: { q: 'ABC123' },
});
// Verify
expect(mockClient.wasCalled('GET', '/api/search')).toBe(true);
expect(results.success).toBe(true);}); }); ```
Using Fixtures
Generate test data consistently:
```typescript import { createMockPart, createMockParts } from '@crop/shared-testing';
describe('Part Processing', () => { test('should process part', () => { const part = createMockPart({ partNumber: 'TEST123', manufacturer: 'TEST', });
const result = processpart(part);
expect(result).toBeDefined();});
test('should process multiple parts', () => { const parts = createMockParts(5); expect(parts).toHaveLength(5); expect(parts[0].id).toBe('mock-part-1'); }); }); ```
Testing Zod Schemas
```typescript import { PartSchema } from '@crop/shared-types';
describe('PartSchema', () => { test('should validate valid part', () => { const validPart = { id: 'part-1', partNumber: 'ABC123', normalizedPartNumber: 'abc123', manufacturer: 'ACME', createdAt: '2024-01-01T00:00:00.000Z', updatedAt: '2024-01-01T00:00:00.000Z', };
const result = PartSchema.safeParse(validPart);
expect(result.success).toBe(true);});
test('should reject invalid part', () => { const invalidPart = { id: 'part-1', // missing required fields };
const result = PartSchema.safeParse(invalidPart);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues).toBeArray();
}}); }); ```
Integration Testing
Testing API Endpoints
```typescript import { AppTester } from '@crop/shared-testing'; import { createApp } from '../app';
describe('Catalog API Integration', () => { const tester = new AppTester(createApp());
describe('GET /api/parts', () => { test('should return paginated results', async () => { const response = await tester.get('/api/parts?page=1&limit=10');
expect(response.status).toBe(200);
expect(response.json).toMatchObject({
success: true,
data: {
items: expect.any(Array),
pagination: {
page: 1,
limit: 10,
total: expect.any(Number),
},
},
});
});
test('should filter by manufacturer', async () => {
const response = await tester.get('/api/parts?manufacturer=ACME');
expect(response.status).toBe(200);
expect(response.json.data.items).toSatisfy(items =>
items.every(item => item.manufacturer === 'ACME')
);
});
test('should return 400 for invalid pagination', async () => {
const response = await tester.get('/api/parts?page=-1');
expect(response.status).toBe(400);
expect(response.json.success).toBe(false);
expect(response.json.error.code).toBe('BAD_REQUEST');
});});
describe('POST /api/parts', () => { test('should create new part', async () => { const response = await tester.post('/api/parts', { body: { partNumber: 'NEW123', manufacturer: 'ACME', description: 'Test part', }, });
expect(response.status).toBe(201);
expect(response.json).toMatchObject({
success: true,
data: {
id: expect.any(String),
partNumber: 'NEW123',
manufacturer: 'ACME',
},
});
});
test('should reject invalid data', async () => {
const response = await tester.post('/api/parts', {
body: {
// missing required fields
description: 'Test',
},
});
expect(response.status).toBe(400);
expect(response.json.success).toBe(false);
});}); }); ```
Testing Inter-Service Communication
```typescript import { MockServiceClient } from '@crop/shared-testing'; import { serviceClients } from '@crop/shared-hono';
describe('Catalog-Search Integration', () => { test('should search parts via search service', async () => { // Setup mock const mockSearch = new MockServiceClient('http://localhost:3001'); mockSearch.mockGet('/api/search', { success: true, data: { results: [ { id: 'part-1', partNumber: 'ABC123', score: 0.95 }, ], }, });
// Test catalog calling search
const catalogResponse = await tester.get('/api/search-parts?q=ABC123');
expect(catalogResponse.status).toBe(200);
expect(catalogResponse.json.data.results).toHaveLength(1);
expect(mockSearch.wasCalled('GET', '/api/search')).toBe(true);});
test('should handle search service errors', async () => { const mockSearch = new MockServiceClient('http://localhost:3001'); mockSearch.mockGet('/api/search', { success: false, error: { code: 'SERVICE_ERROR', message: 'Search service unavailable', }, });
const response = await tester.get('/api/search-parts?q=ABC123');
expect(response.status).toBe(500);
expect(response.json.error.code).toBe('SEARCH_SERVICE_ERROR');}); }); ```
E2E Testing
Docker-Based E2E Tests
```typescript // tests/e2e/part-search.test.ts import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
describe('Part Search E2E', () => { beforeAll(async () => { // Ensure services are running // You might use docker-compose or check health endpoints });
test('complete search workflow', async () => { // 1. Search for part const searchResponse = await fetch('http://localhost:3001/api/search?q=ABC123'); const searchData = await searchResponse.json();
expect(searchData.success).toBe(true);
expect(searchData.data.results.length).toBeGreaterThan(0);
// 2. Get first result details from catalog
const partId = searchData.data.results[0].id;
const catalogResponse = await fetch(\`http://localhost:3003/api/parts/\${partId}\`);
const catalogData = await catalogResponse.json();
expect(catalogData.success).toBe(true);
expect(catalogData.data.partNumber).toBe('ABC123');
// 3. Check fitment
const fitmentResponse = await fetch(\`http://localhost:3002/api/fitment?partId=\${partId}\`);
const fitmentData = await fitmentResponse.json();
expect(fitmentData.success).toBe(true);
expect(fitmentData.data.equipment).toBeArray();});
test('search with no results', async () => { const response = await fetch('http://localhost:3001/api/search?q=NONEXISTENT999'); const data = await response.json();
expect(data.success).toBe(true);
expect(data.data.results).toHaveLength(0);}); }); ```
Running E2E Tests
```bash
Start all services
make dev
Wait for services to be healthy
sleep 10
Run E2E tests
bun test tests/e2e
Clean up
make down ```
Testing Best Practices
1. Test Naming
Good: ```typescript test('should return 404 when part not found', async () => { // Clear and specific }); ```
Bad: ```typescript test('test1', async () => { // Unclear what is being tested }); ```
2. Arrange-Act-Assert Pattern
```typescript test('should calculate total price', () => { // Arrange const cart = { items: [{ price: 10 }, { price: 20 }] };
// Act const total = calculateTotal(cart);
// Assert expect(total).toBe(30); }); ```
3. One Assertion Per Test (guideline, not rule)
```typescript // Prefer focused tests test('should return correct status code', async () => { const response = await tester.get('/api/parts'); expect(response.status).toBe(200); });
test('should return valid structure', async () => { const response = await tester.get('/api/parts'); expect(response.json).toHaveProperty('success'); expect(response.json).toHaveProperty('data'); }); ```
4. Test Independence
```typescript // BAD - tests depend on each other let createdPartId: string;
test('should create part', async () => { const response = await tester.post('/api/parts', { body: {...} }); createdPartId = response.json.data.id; // State shared between tests });
test('should get part', async () => { const response = await tester.get(`/api/parts/${createdPartId}`); // Fails if previous test fails });
// GOOD - tests are independent test('should create and retrieve part', async () => { // Create const createResponse = await tester.post('/api/parts', { body: {...} }); const partId = createResponse.json.data.id;
// Get const getResponse = await tester.get(`/api/parts/${partId}`); expect(getResponse.status).toBe(200); }); ```
5. Avoid Testing Implementation Details
```typescript // BAD - testing internal implementation test('should use redis cache', () => { const spy = jest.spyOn(redis, 'get'); getUser('123'); expect(spy).toHaveBeenCalled(); });
// GOOD - testing behavior test('should return user quickly on second request', async () => { const start1 = Date.now(); await getUser('123'); const time1 = Date.now() - start1;
const start2 = Date.now(); await getUser('123'); const time2 = Date.now() - start2;
expect(time2).toBeLessThan(time1); }); ```
6. Use Test Data Builders
```typescript // Use fixture functions const part = createMockPart({ partNumber: 'SPECIFIC123', manufacturer: 'TEST', });
// Or create builders for complex objects class PartBuilder { private part: Partial = {};
withPartNumber(pn: string) { this.part.partNumber = pn; return this; }
withManufacturer(mfr: string) { this.part.manufacturer = mfr; return this; }
build(): Part { return { id: this.part.id || 'default-id', partNumber: this.part.partNumber || 'DEFAULT', manufacturer: this.part.manufacturer || 'ACME', // ... other required fields with defaults }; } }
const part = new PartBuilder() .withPartNumber('ABC123') .withManufacturer('ACME') .build(); ```
CI/CD Integration
GitHub Actions Workflow
Our CI workflow (.github/workflows/ci.yml) runs:
- Lint - Biome check
- Type Check - TypeScript compilation
- Unit Tests - All package and service tests
- Integration Tests - With MongoDB and Elasticsearch
- Build - Ensure all services build
Test Requirements for PR Merge
- All tests must pass
- Test coverage must not decrease
- No linting errors
- TypeScript compilation must succeed
Running Tests Locally Like CI
```bash
Full CI check
bun run lint && bun test && bun run build
Or use script
bun run ci ```
Troubleshooting
Tests Failing Locally But Passing in CI
Cause: Different environments or cached data
Solution: ```bash
Clean install
rm -rf node_modules bun.lockb bun install
Clear test cache
rm -rf .bun-cache ```
Slow Tests
Cause: Too many integration/E2E tests or inefficient setup
Solution:
- Add
--timeoutflag:bun test --timeout 10000 - Run specific test files:
bun test src/__tests__/fast.test.ts - Use
test.only()during development - Optimize database seeds
Flaky Tests
Cause: Race conditions, timing issues, external dependencies
Solution:
- Add proper
awaitstatements - Use
beforeEach/afterEachfor cleanup - Mock external services
- Add retries for E2E tests (sparingly)
Mock Not Working
Cause: Import order or module caching
Solution: ```typescript // Ensure mocks are set up before imports import { MockServiceClient } from '@crop/shared-testing';
// Create mock before using const mock = new MockServiceClient('http://localhost:3001'); mock.mockGet('/api/search', { ... }); ```
Summary
This testing guide provides:
- ✅ Clear testing strategy with test pyramid
- ✅ Practical examples for unit, integration, and E2E tests
- ✅ Testing utilities from
@crop/shared-testing - ✅ Best practices for maintainable tests
- ✅ CI/CD integration for automated testing
- ✅ Troubleshooting common issues
Next Steps:
- Run
bun testto see all tests passing - Add tests for new features following this guide
- Review test coverage:
bun test --coverage - Set up CI/CD pipeline if not already configured
Need Help?
- Check test examples in
packages/shared-types/src/__tests__/ - Review
@crop/shared-testingutilities - See CI workflow in
.github/workflows/ci.yml