Testing Strategy
Philosophy: Test Behavior, Not Implementation
Our testing strategy prioritizes developer velocity and actual bug prevention over coverage metrics. We test things that:
- Have complex business logic
- Are likely to break silently
- Would cause significant user impact if broken
- Have broken before (regression prevention)
We explicitly don't test things that:
- Change frequently with UI iterations
- Would only break in obvious, visible ways
- Are simple pass-through or rendering logic
Testing Pyramid for This Project
E2E Tests (3-5 tests)
/ \
/ Integration Tests \
/ (Redux + Domain) \
/ \
/ Unit Tests \
/ (Pure Functions Only) \
/_________________________________ \
NO UI Component Tests
1. UI Component Testing - DON'T DO THIS
What NOT to Test
// BAD: Testing implementation details
test('renders three Grid components', () => {
const wrapper = render(<DashboardScreen />);
expect(wrapper.find('Grid')).toHaveLength(3);
});
// BAD: Testing MUI component props
test('button has correct color', () => {
render(<SubmitButton />);
expect(screen.getByRole('button')).toHaveAttribute('color', 'primary');
});
// BAD: Testing state changes
test('sets loading to true when fetching', () => {
const wrapper = shallow(<ProjectList />);
wrapper.instance().fetchProjects();
expect(wrapper.state('isLoading')).toBe(true);
});
// BAD: Testing that things render
test('displays user name', () => {
render(<UserProfile user={{ name: 'John' }} />);
expect(screen.getByText('John')).toBeInTheDocument();
});
Why We Don't Test UI Components
- High Maintenance: Every UI change breaks tests
- Low Value: TypeScript already catches type errors
- False Security: Tests pass but app still broken
- Obvious Failures: If a button doesn't render, you'll see it immediately
2. Redux Store Testing - DO THIS
Test Reducers with Business Logic
// GOOD: Testing complex state transitions
// src/store/__tests__/projectFile.test.js
import { reducer, actions, selectors } from '../projectFile';
import { ProjectFile, ProjectFileArray } from '../../domain';
describe('projectFile reducer', () => {
const initialState = {
current: {
data: new ProjectFile(),
isLoading: false,
isLoaded: false,
},
list: {
data: {},
isLoading: {},
isLoaded: {},
},
error: null,
};
test('GENERATE_ESTIMATE_FULFILLED updates current and list', () => {
const projectId = 'proj-123';
const projectFile = new ProjectFile({
id: 'file-456',
name: 'Test Estimate',
type: ProjectFile.TYPE_ESTIMATE_PDF,
status: ProjectFile.STATUS_DONE,
project: { id: projectId }
});
// Setup initial state with existing list
const stateWithList = {
...initialState,
list: {
data: {
[projectId]: new ProjectFileArray([
new ProjectFile({ id: 'old-file', name: 'Old File' })
])
},
isLoading: { [projectId]: false },
isLoaded: { [projectId]: true }
}
};
const action = {
type: 'GENERATE_ESTIMATE_PROJECT_FILE_FULFILLED',
payload: projectFile
};
const newState = reducer(stateWithList, action);
// Test that current is updated
expect(newState.current.data.id).toBe('file-456');
expect(newState.current.data.name).toBe('Test Estimate');
expect(newState.current.isLoading).toBe(false);
expect(newState.current.isLoaded).toBe(true);
// Test that list is updated with new file
const projectList = newState.list.data[projectId];
expect(projectList).toHaveLength(2);
expect(projectList.get('file-456')).toBeDefined();
});
test('handles concurrent loading states for multiple projects', () => {
const action1 = {
type: 'LIST_PROJECT_FILE_PENDING',
meta: { project: { id: 'proj-1' } }
};
const action2 = {
type: 'LIST_PROJECT_FILE_PENDING',
meta: { project: { id: 'proj-2' } }
};
let state = reducer(initialState, action1);
state = reducer(state, action2);
expect(state.list.isLoading['proj-1']).toBe(true);
expect(state.list.isLoading['proj-2']).toBe(true);
expect(state.error).toBeNull();
});
test('clears state on logout', () => {
const stateWithData = {
...initialState,
current: {
data: new ProjectFile({ id: 'test' }),
isLoading: false,
isLoaded: true,
}
};
const action = { type: 'LOGOUT_USER_PENDING' };
const newState = reducer(stateWithData, action);
expect(newState).toEqual(initialState);
});
});
// GOOD: Testing selectors with complex logic
describe('projectFile selectors', () => {
test('list selector returns empty array for non-existent project', () => {
const state = {
projectFile: {
list: { data: {} }
}
};
const result = selectors.list(state, 'non-existent-id');
expect(result).toBeInstanceOf(ProjectFileArray);
expect(result).toHaveLength(0);
});
test('list selector returns correct project files', () => {
const projectFiles = new ProjectFileArray([
new ProjectFile({ id: 'file-1', name: 'File 1' }),
new ProjectFile({ id: 'file-2', name: 'File 2' })
]);
const state = {
projectFile: {
list: {
data: { 'proj-123': projectFiles }
}
}
};
const result = selectors.list(state, 'proj-123');
expect(result).toHaveLength(2);
expect(result.get('file-1').name).toBe('File 1');
});
});
Test Async Action Creators
// GOOD: Testing action creators return correct structure
describe('projectFile actions', () => {
test('generateEstimate creates correct action structure', () => {
const project = new Project({ id: 'proj-123', name: 'Test' });
const action = actions.generateEstimate(project);
expect(action.type).toBe('GENERATE_ESTIMATE_PROJECT_FILE');
expect(action.meta.project).toBe(project);
expect(action.payload).toBeInstanceOf(Promise);
});
test('list action includes proper Parse query', () => {
const project = new Project({ id: 'proj-123' });
const action = actions.list(project);
expect(action.type).toBe('LIST_PROJECT_FILE');
expect(action.meta.project).toBe(project);
// Note: We can't easily test the Parse.Query internals,
// but we can verify it returns a Promise
expect(action.payload).toBeInstanceOf(Promise);
});
});
3. Domain Model Testing - DO THIS
Test Business Logic and Validation
// GOOD: Testing domain model business rules
// src/domain/__tests__/ProjectFile.test.js
import { ProjectFile } from '../ProjectFile';
describe('ProjectFile Domain Model', () => {
describe('isSavable validation', () => {
test('requires name to be savable', () => {
const file = new ProjectFile({
type: ProjectFile.TYPE_ESTIMATE_PDF,
status: ProjectFile.STATUS_PENDING
});
expect(file.isSavable()).toBe(false);
file.name = 'Valid Name';
expect(file.isSavable()).toBe(true);
});
test('requires valid status', () => {
const file = new ProjectFile({
name: 'Test File',
type: ProjectFile.TYPE_ESTIMATE_PDF,
status: 'INVALID_STATUS'
});
expect(file.isSavable()).toBe(false);
});
test('requires valid type', () => {
const file = new ProjectFile({
name: 'Test File',
status: ProjectFile.STATUS_PENDING,
type: 'INVALID_TYPE'
});
expect(file.isSavable()).toBe(false);
});
});
describe('url getter', () => {
test('returns null when no file', () => {
const projectFile = new ProjectFile();
expect(projectFile.url).toBeNull();
});
test('handles Parse.File with url function', () => {
const mockFile = {
url: jest.fn().mockReturnValue('https://example.com/file.pdf')
};
const projectFile = new ProjectFile({ file: mockFile });
expect(projectFile.url).toBe('https://example.com/file.pdf');
expect(mockFile.url).toHaveBeenCalled();
});
test('handles plain object with url property', () => {
const mockFile = {
url: 'https://example.com/file.pdf'
};
const projectFile = new ProjectFile({ file: mockFile });
expect(projectFile.url).toBe('https://example.com/file.pdf');
});
});
describe('status helpers', () => {
test('identifies correct status', () => {
const pendingFile = new ProjectFile({ status: ProjectFile.STATUS_PENDING });
const doneFile = new ProjectFile({ status: ProjectFile.STATUS_DONE });
expect(pendingFile.status).toBe('PENDING');
expect(doneFile.status).toBe('DONE');
});
});
});
Test Collection Operations
// GOOD: Testing domain array operations
// src/domain/__tests__/ProjectFileArray.test.js
describe('ProjectFileArray', () => {
test('addUpdate replaces existing item', () => {
const array = new ProjectFileArray([
new ProjectFile({ id: 'file-1', name: 'Original' })
]);
const updated = new ProjectFile({ id: 'file-1', name: 'Updated' });
array.addUpdate(updated);
expect(array).toHaveLength(1);
expect(array.get('file-1').name).toBe('Updated');
});
test('addUpdate adds new item if not exists', () => {
const array = new ProjectFileArray([
new ProjectFile({ id: 'file-1', name: 'First' })
]);
const newFile = new ProjectFile({ id: 'file-2', name: 'Second' });
array.addUpdate(newFile);
expect(array).toHaveLength(2);
expect(array.get('file-2').name).toBe('Second');
});
test('remove deletes item by id', () => {
const array = new ProjectFileArray([
new ProjectFile({ id: 'file-1' }),
new ProjectFile({ id: 'file-2' }),
new ProjectFile({ id: 'file-3' })
]);
array.remove({ id: 'file-2' });
expect(array).toHaveLength(2);
expect(array.get('file-2')).toBeUndefined();
expect(array.get('file-1')).toBeDefined();
expect(array.get('file-3')).toBeDefined();
});
});
4. E2E Testing - DO THIS SPARINGLY
E2E Testing Principles
- Test complete user journeys, not individual pages
- Test the happy path and one or two critical error paths
- Keep tests independent - each test should work in isolation
- Use realistic data but clean up after yourself
- Test what would cause a customer support ticket if broken
E2E Test Examples
// GOOD: Testing critical user journey
// tests/e2e/estimate-generation.spec.js
import { test, expect } from '@playwright/test';
test.describe('Estimate Generation Flow', () => {
test('anonymous user can generate and download estimate', async ({ page }) => {
// Start at home page
await page.goto('/');
// Fill out project details
await page.fill('[name="projectName"]', 'E2E Test Project');
await page.fill('[name="projectDescription"]', 'Automated test project');
// Select some features
await page.click('text=User Authentication');
await page.click('text=Payment Processing');
await page.click('text=Email Notifications');
// Submit form
await page.click('button:has-text("Generate Estimate")');
// Verify preview dialog appears
await expect(page.locator('text=Generating Project Plan')).toBeVisible();
// Verify progress bar
await expect(page.locator('[role="progressbar"]')).toBeVisible();
// Verify validation options appear
await expect(page.locator('text=Create Account')).toBeVisible({ timeout: 35000 });
await expect(page.locator('text=Validate With Google')).toBeVisible();
});
test('authenticated user can save and access project', async ({ page }) => {
// Login first
await page.goto('/');
await loginAsTestUser(page);
// Create project
await page.fill('[name="projectName"]', 'Saved Test Project');
await page.click('button:has-text("Generate Estimate")');
// Wait for processing
await expect(page.locator('text=Download Estimate')).toBeVisible({ timeout: 60000 });
// Verify project is saved
await page.click('button[aria-label="Close"]');
await page.goto('/dashboard');
await expect(page.locator('text=Saved Test Project')).toBeVisible();
// Verify can reopen project
await page.click('text=Saved Test Project');
await expect(page.url()).toContain('/estimate-project/');
await expect(page.locator('[name="projectName"]')).toHaveValue('Saved Test Project');
});
});
// Helper function
async function loginAsTestUser(page) {
await page.click('button:has-text("Sign In")');
await page.fill('[name="email"]', 'test@example.com');
await page.fill('[name="password"]', 'TestPassword123!');
await page.click('button[type="submit"]');
await page.waitForSelector('text=Dashboard');
}
What TO Test with E2E
// GOOD: Critical business flows
test('user can complete purchase', async ({ page }) => {
// Test the entire flow from selection to payment confirmation
});
// GOOD: Integration between systems
test('Parse auth works with Google OAuth', async ({ page }) => {
// Test that Google login creates Parse session
});
// GOOD: Complex multi-step workflows
test('project moves through all status stages', async ({ page }) => {
// Create, process, complete, archive
});
// GOOD: Error recovery
test('handles network failure during estimate generation', async ({ page }) => {
// Simulate network failure and verify graceful handling
});
What NOT to Test with E2E
// BAD: Testing individual UI components
test('button changes color on hover', async ({ page }) => {
// Use CSS and visual testing for this
});
// BAD: Testing form validation for every field
test('shows error for invalid email format', async ({ page }) => {
// Test one validation in your critical path, not all
});
// BAD: Testing every possible path
test('all 47 combinations of features work', async ({ page }) => {
// Test the most common 2-3 combinations only
});
// BAD: Testing static content
test('footer contains copyright text', async ({ page }) => {
// This will never break in a way that matters
});
5. Utility Function Testing - DO THIS
// GOOD: Testing pure utility functions
// src/utils/__tests__/calculations.test.js
describe('price calculations', () => {
test('calculates estimate with correct multipliers', () => {
const features = [
{ baseHours: 10, complexity: 1.5 },
{ baseHours: 20, complexity: 2.0 }
];
expect(calculateTotalHours(features)).toBe(55); // (10*1.5) + (20*2.0)
});
test('applies team size penalties correctly', () => {
const baseHours = 100;
expect(applyTeamSizePenalty(baseHours, 1)).toBe(100);
expect(applyTeamSizePenalty(baseHours, 2)).toBe(110); // 10% overhead
expect(applyTeamSizePenalty(baseHours, 5)).toBe(140); // 40% overhead
});
});
Testing Commands
{
"scripts": {
// Run only valuable tests (skip UI components)
"test": "vitest src/store src/domain src/utils",
// Run tests in watch mode during development
"test:watch": "vitest src/store src/domain src/utils --watch",
// Run E2E tests (separate, slower)
"test:e2e": "playwright test",
// Run E2E tests in UI mode for debugging
"test:e2e:ui": "playwright test --ui",
// Full test suite for CI/CD
"test:all": "npm run test && npm run test:e2e",
// Skip tests, just check types and linting
"check": "tsc --noEmit && eslint ."
}
}
When to Add New Tests
Add a Test When:
You fix a bug - Prevent regression
test('handles undefined project.id gracefully', () => {
// Test for the bug you just fixed
});Logic is complex - More than 2-3 conditional branches
test('calculateOptimalTeamSize handles all project sizes', () => {
// Complex algorithm needs tests
});Multiple developers touch the code - Prevent breaking changes
test('API contract remains consistent', () => {
// Ensure interface doesn't change
});Cost of failure is high - Payment, auth, data loss
test('never processes payment twice', () => {
// Critical business logic
});
Don't Add a Test When:
- It's just rendering - TypeScript + eyeballs are enough
- It would test MUI - Trust the library
- The failure would be obvious - If it breaks, you'll see it
- It's a simple pass-through - No logic to test
Test File Organization
src/
├── store/
│ ├── __tests__/
│ │ ├── projectFile.test.js # Reducer & selector tests
│ │ └── auth.test.js
│ └── projectFile.js
├── domain/
│ ├── __tests__/
│ │ ├── ProjectFile.test.js # Domain model tests
│ │ └── ProjectFileArray.test.js
│ └── ProjectFile.js
├── utils/
│ ├── __tests__/
│ │ └── calculations.test.js # Utility function tests
│ └── calculations.js
└── components/ # NO TESTS HERE
└── EstimatorScreen.jsx
tests/
└── e2e/
├── estimate-generation.spec.js
├── auth-flow.spec.js
└── helpers/
└── auth.js # Shared E2E helpers
Summary
- Test behavior and business logic, not implementation
- Skip UI component unit tests entirely
- Focus on Redux reducers, domain models, and utilities
- Write 3-5 E2E tests for critical paths
- Prioritize developer velocity over coverage metrics
- Add tests when fixing bugs to prevent regression
Remember: A test that never catches bugs is worse than no test at all - it's technical debt that slows you down without providing value.