Let's talk about testing in fullstack applications - not just the theory, but the real, messy, production-grade challenges we face every day. After years of building and maintaining large-scale applications, I've learned that testing isn't just about writing tests; it's about understanding what to test, when to test it, and how to make testing work for your team.
In my experience, the key to effective testing isn't about following a strict pyramid or achieving arbitrary coverage numbers. It's about understanding what needs to be tested and when. Let me share what works in practice:
I've found that a balanced testing strategy works best:
Here's a practical example of how I structure tests:
// Complex business logic deserves unit tests
describe('OrderValidator', () => {
it('should validate order with complex business rules', () => {
const order = createOrderWithComplexRules();
const result = validateOrder(order);
expect(result.isValid).toBe(true);
});
});
// Integration tests for user flows
describe('User Registration Flow', () => {
it('should complete registration and redirect to dashboard', async () => {
const user = await registerUser({
email: 'test@example.com',
password: 'validPassword123',
});
expect(user.status).toBe('active');
expect(user.redirectUrl).toBe('/dashboard');
});
// Negative scenarios
it('should handle invalid password format', async () => {
const result = await registerUser({
email: 'test@example.com',
password: 'weak',
});
expect(result.error).toBe('Password must be at least 8 characters');
});
});
When it comes to E2E testing, I follow a "critical paths only" approach. Here's why:
Instead, I focus E2E tests on:
// cypress/e2e/critical-flows.cy.ts
describe('Critical User Journeys', () => {
// Only test the most important flows
it('should complete checkout process', () => {
// ... checkout flow
});
it('should handle user authentication', () => {
// ... auth flow
});
it('should process payment successfully', () => {
// ... payment flow
});
});
Integration tests are where I invest most of my testing effort. They're:
Here's how I structure them:
// tests/integration/user-flows.test.ts
describe('User Account Management', () => {
it('should update profile and verify changes', async () => {
// Setup
const user = await createUser();
const newProfile = {
name: 'Updated Name',
email: 'new@example.com',
};
// Action
await updateProfile(user.id, newProfile);
// Verification
const updatedUser = await getUser(user.id);
expect(updatedUser.name).toBe(newProfile.name);
expect(updatedUser.email).toBe(newProfile.email);
});
it('should handle concurrent profile updates', async () => {
const user = await createUser();
const updates = [{ name: 'Update 1' }, { name: 'Update 2' }];
await Promise.all(updates.map((update) => updateProfile(user.id, update)));
const finalUser = await getUser(user.id);
expect(finalUser.name).toBeOneOf(['Update 1', 'Update 2']);
});
});
For unit tests, I focus on:
// tests/unit/complex-logic.test.ts
describe('Order Processing Logic', () => {
it('should apply complex discount rules', () => {
const order = createOrderWithMultipleItems();
const discount = calculateDiscount(order);
expect(discount).toBe(150); // Complex calculation result
});
it('should handle edge cases in price calculation', () => {
const edgeCaseOrder = createEdgeCaseOrder();
const price = calculatePrice(edgeCaseOrder);
expect(price).toBeDefined();
expect(price).not.toBeNaN();
});
});
Here's my hot take - TypeScript is your first line of testing. The stronger your types, the fewer tests you need to write. Let me show you why:
// Without TypeScript
function processOrder(order) {
return order.items.map((item) => {
return {
name: item.name,
price: item.price,
quantity: item.quantity,
};
});
}
// With TypeScript
interface OrderItem {
name: string;
price: number;
quantity: number;
}
interface Order {
id: string;
items: OrderItem[];
status: 'pending' | 'processing' | 'completed';
}
function processOrder(order: Order): OrderItem[] {
return order.items.map((item) => ({
name: item.name,
price: item.price,
quantity: item.quantity,
}));
}
With TypeScript, we get:
Let's talk about the real tools we use in production:
// tests/userService.test.ts
import { UserService } from '../services/userService';
describe('UserService', () => {
let userService: UserService;
let mockDb: jest.Mocked<Database>;
beforeEach(() => {
mockDb = {
findUser: jest.fn(),
createUser: jest.fn(),
};
userService = new UserService(mockDb);
});
// Integration test with mocked dependencies
it('should create user and send welcome email', async () => {
const user = await userService.createUser({
email: 'test@example.com',
name: 'Test User',
});
expect(mockDb.createUser).toHaveBeenCalledWith({
email: 'test@example.com',
name: 'Test User',
});
expect(user.welcomeEmailSent).toBe(true);
});
});
// cypress/e2e/checkout.cy.ts
describe('Checkout Flow', () => {
it('should complete checkout process', () => {
cy.visit('/products');
cy.get('[data-testid="product-1"]').click();
cy.get('[data-testid="add-to-cart"]').click();
cy.get('[data-testid="checkout"]').click();
// Fill shipping info
cy.get('[data-testid="shipping-form"]').within(() => {
cy.get('input[name="name"]').type('Test User');
cy.get('input[name="address"]').type('123 Test St');
// ... more form filling
});
// Complete payment
cy.get('[data-testid="payment-form"]').within(() => {
cy.get('input[name="card"]').type('4242424242424242');
// ... more payment details
});
cy.get('[data-testid="place-order"]').click();
cy.url().should('include', '/order-confirmation');
});
});
In a team setting, testing becomes even more crucial. Here's what I've found works well:
PR Requirements:
CI/CD Pipeline:
Team Standards:
One of the biggest mistakes teams make is waiting until production to test performance. Here's how we do it:
// tests/performance/orderProcessing.test.ts
describe('Order Processing Performance', () => {
it('should handle 1000 concurrent orders', async () => {
const orders = Array(1000)
.fill(null)
.map((_, i) => ({
id: `order-${i}`,
items: [{ productId: '1', quantity: 1 }],
}));
const start = Date.now();
await Promise.all(orders.map(processOrder));
const duration = Date.now() - start;
expect(duration).toBeLessThan(5000); // 5 seconds max
expect(await getOrderCount()).toBe(1000);
});
});
Security testing isn't just about checking for SQL injection. It's about:
// tests/security/auth.test.ts
describe('Authentication Security', () => {
it('should prevent brute force attacks', async () => {
const attempts = 10;
for (let i = 0; i < attempts; i++) {
await login('test@example.com', 'wrong-password');
}
const response = await login('test@example.com', 'correct-password');
expect(response.status).toBe(429); // Too Many Requests
});
it('should validate JWT tokens properly', async () => {
const token = await getAuthToken();
const modifiedToken = modifyToken(token);
const response = await makeAuthenticatedRequest(modifiedToken);
expect(response.status).toBe(401);
});
});
Testing isn't about achieving 100% coverage or writing tests for every function. It's about:
Remember: Write tests that matter, not just tests that exist. Focus on critical paths, user journeys, and edge cases. And most importantly, make testing work for your team, not against it.
Got questions about implementing any of these testing strategies? Feel free to reach out. Happy testing!