Skip to main content

Testing Standards

Testing Philosophy at Kirana Labs

We believe that testing is an integral part of the development process, not an afterthought. Well-tested code leads to higher quality, fewer bugs, and more maintainable applications. This document outlines our approach to testing across our technology stack.

Test Coverage Goals

We aim for the following test coverage in our projects:

Project TypeUnit TestsIntegration TestsE2E Tests
Client Projects70%+Critical flowsKey user journeys
Internal Tools60%+As neededOptional

Testing Pyramid

We follow the testing pyramid approach, with:

  1. Many unit tests (fast, focused on business logic)
  2. Some integration tests (testing component interactions)
  3. Few end-to-end tests (slow but comprehensive)

Types of Tests

Unit Tests

Unit tests are focused on testing individual functions, methods, or components in isolation.

When to write unit tests:

  • For complex business logic
  • For utility functions
  • For critical algorithms
  • For reusable components

Frameworks and Tools:

  • Jest for JavaScript/TypeScript
  • React Testing Library for React components
  • pytest for Python/Django

Example Unit Test (TypeScript with Jest):

// utils/formatCurrency.ts
export function formatCurrency(value: number, locale = 'en-US', currency = 'USD'): string {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency,
}).format(value);
}

// utils/formatCurrency.test.ts
import { formatCurrency } from './formatCurrency';

describe('formatCurrency', () => {
it('formats USD correctly', () => {
expect(formatCurrency(1000)).toBe('$1,000.00');
});

it('formats EUR correctly', () => {
expect(formatCurrency(1000, 'de-DE', 'EUR')).toBe('1.000,00 €');
});

it('handles zero correctly', () => {
expect(formatCurrency(0)).toBe('$0.00');
});

it('handles negative values correctly', () => {
expect(formatCurrency(-1000)).toBe('-$1,000.00');
});
});

Integration Tests

Integration tests verify that different parts of the application work together correctly.

When to write integration tests:

  • For testing API endpoints
  • For database interactions
  • For component compositions
  • For form submissions

Frameworks and Tools:

  • Jest with supertest for API testing
  • React Testing Library for testing component interactions
  • Django Test Client for Django views

Example Integration Test (API Endpoint):

// __tests__/api/users.test.ts
import { createMocks } from 'node-mocks-http';
import { handler } from '@/app/api/users/route';

describe('Users API', () => {
it('returns a list of users', async () => {
const { req, res } = createMocks({
method: 'GET',
});

await handler(req, res);

expect(res._getStatusCode()).toBe(200);
const data = JSON.parse(res._getData());
expect(Array.isArray(data.users)).toBe(true);
});

it('creates a new user', async () => {
const { req, res } = createMocks({
method: 'POST',
body: {
name: 'Test User',
email: 'test@example.com',
role: 'user',
},
});

await handler(req, res);

expect(res._getStatusCode()).toBe(201);
const data = JSON.parse(res._getData());
expect(data.user).toHaveProperty('id');
expect(data.user.name).toBe('Test User');
});

it('validates input data', async () => {
const { req, res } = createMocks({
method: 'POST',
body: {
name: 'Test User',
// Missing email
},
});

await handler(req, res);

expect(res._getStatusCode()).toBe(400);
const data = JSON.parse(res._getData());
expect(data).toHaveProperty('error');
});
});

End-to-End (E2E) Tests

E2E tests verify that entire user flows work as expected from start to finish.

When to write E2E tests:

  • For critical user journeys
  • For authentication flows
  • For checkout processes
  • For form submissions with side effects

Frameworks and Tools:

  • Playwright or Cypress for web applications

Example E2E Test (Playwright):

// tests/e2e/login.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Authentication flow', () => {
test('successful login', async ({ page }) => {
// Navigate to login page
await page.goto('/login');

// Fill in credentials
await page.fill('[name="email"]', 'test@example.com');
await page.fill('[name="password"]', 'password123');

// Click login button
await page.click('button[type="submit"]');

// Verify redirection to dashboard
await expect(page).toHaveURL(/dashboard/);

// Verify user is logged in
const userMenu = page.locator('[data-testid="user-menu"]');
await expect(userMenu).toBeVisible();
await expect(userMenu).toContainText('Test User');
});

test('unsuccessful login with wrong password', async ({ page }) => {
await page.goto('/login');
await page.fill('[name="email"]', 'test@example.com');
await page.fill('[name="password"]', 'wrongpassword');
await page.click('button[type="submit"]');

// Should stay on login page
await expect(page).toHaveURL(/login/);

// Should show error message
const errorMessage = page.locator('[data-testid="login-error"]');
await expect(errorMessage).toBeVisible();
await expect(errorMessage).toContainText('Invalid credentials');
});
});

Test Structure and Organization

Directory Structure

project/
├── __tests__/ # Jest tests
│ ├── unit/ # Unit tests
│ ├── integration/ # Integration tests
│ └── e2e/ # End-to-end tests (or in separate directory)
├── src/
│ ├── components/
│ │ ├── Button.tsx
│ │ └── Button.test.tsx # Component tests next to implementation
│ ├── utils/
│ │ ├── format.ts
│ │ └── format.test.ts # Utility tests next to implementation
│ └── ...
└── playwright/ # Playwright E2E tests in separate directory
├── tests/
│ └── login.spec.ts
└── playwright.config.ts

Test File Naming Conventions

  • Unit/Integration Tests: *.test.ts or *.spec.ts
  • E2E Tests: *.spec.ts (Playwright/Cypress)

Writing Effective Tests

Test Organization (Arrange-Act-Assert)

Follow the AAA pattern for clear, maintainable tests:

  1. Arrange: Set up test data and conditions
  2. Act: Perform the action being tested
  3. Assert: Verify the result is as expected
test('adds items to cart', async () => {
// Arrange
const product = { id: '123', name: 'Test Product', price: 9.99 };
const cart = new ShoppingCart();

// Act
cart.addItem(product, 2);

// Assert
expect(cart.items).toHaveLength(1);
expect(cart.items[0].product).toEqual(product);
expect(cart.items[0].quantity).toBe(2);
expect(cart.total).toBe(19.98);
});

Test Naming

Use descriptive test names that explain:

  1. What is being tested
  2. Under what conditions
  3. What the expected result is

Format: it('should [expected behavior] when [condition]')

// Good test names
it('should format currency with correct symbols', () => {});
it('should throw error when email is invalid', () => {});
it('should add product to cart when in stock', () => {});

// Bad test names
it('test formatCurrency', () => {});
it('works correctly', () => {});

Mocking and Test Doubles

Use mocks and test doubles to isolate the code being tested:

// Example of mocking a database service
jest.mock('@/lib/db', () => ({
getUserById: jest.fn().mockImplementation((id) => {
if (id === 'valid-id') {
return Promise.resolve({
id: 'valid-id',
name: 'Test User',
email: 'test@example.com',
});
}
return Promise.resolve(null);
}),
createUser: jest.fn().mockImplementation((data) => {
return Promise.resolve({
id: 'new-id',
...data,
});
}),
}));

// In test
import { getUserById, createUser } from '@/lib/db';

test('getUserById returns user for valid ID', async () => {
const user = await getUserById('valid-id');
expect(user).toHaveProperty('name', 'Test User');
});

Testing React Components

Component Testing Principles

  1. Test behavior, not implementation
  2. Focus on user interaction, not internal state
  3. Use data-testid attributes for test selectors
  4. Test accessibility where relevant

Example Component Test

// components/LoginForm.tsx
import { useState } from 'react';

interface LoginFormProps {
onSubmit: (credentials: { email: string; password: string }) => void;
isLoading?: boolean;
}

export function LoginForm({ onSubmit, isLoading = false }: LoginFormProps) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit({ email, password });
};

return (
<form onSubmit={handleSubmit} data-testid="login-form">
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
data-testid="email-input"
/>
</div>
<div>
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
data-testid="password-input"
/>
</div>
<button
type="submit"
disabled={isLoading}
data-testid="login-button"
>
{isLoading ? 'Logging in...' : 'Log in'}
</button>
</form>
);
}

// components/LoginForm.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { LoginForm } from './LoginForm';

describe('LoginForm', () => {
it('submits the form with email and password', () => {
const handleSubmit = jest.fn();
render(<LoginForm onSubmit={handleSubmit} />);

// Fill in the form
fireEvent.change(screen.getByTestId('email-input'), {
target: { value: 'test@example.com' },
});
fireEvent.change(screen.getByTestId('password-input'), {
target: { value: 'password123' },
});

// Submit the form
fireEvent.click(screen.getByTestId('login-button'));

// Verify submission
expect(handleSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123',
});
});

it('disables the submit button when loading', () => {
render(<LoginForm onSubmit={jest.fn()} isLoading={true} />);

const button = screen.getByTestId('login-button');
expect(button).toBeDisabled();
expect(button).toHaveTextContent('Logging in...');
});
});

Testing API Routes and Serverless Functions

// Example of testing a NextJS API route
import { createMocks } from 'node-mocks-http';
import { handler } from '@/app/api/auth/login/route';
import { authenticateUser } from '@/lib/auth';

// Mock dependencies
jest.mock('@/lib/auth', () => ({
authenticateUser: jest.fn(),
}));

describe('Login API', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('returns 200 and user data when login is successful', async () => {
const mockUser = { id: '123', email: 'test@example.com', name: 'Test User' };
(authenticateUser as jest.Mock).mockResolvedValue(mockUser);

const { req, res } = createMocks({
method: 'POST',
body: {
email: 'test@example.com',
password: 'password123',
},
});

await handler(req, res);

expect(res._getStatusCode()).toBe(200);
expect(JSON.parse(res._getData())).toEqual({
success: true,
user: mockUser,
});
});

it('returns 401 when credentials are invalid', async () => {
(authenticateUser as jest.Mock).mockResolvedValue(null);

const { req, res } = createMocks({
method: 'POST',
body: {
email: 'test@example.com',
password: 'wrongpassword',
},
});

await handler(req, res);

expect(res._getStatusCode()).toBe(401);
expect(JSON.parse(res._getData())).toEqual({
success: false,
error: 'Invalid credentials',
});
});
});

Test-Driven Development (TDD)

For critical features or complex logic, we encourage using Test-Driven Development:

  1. Write a failing test that defines the expected behavior
  2. Write the minimal implementation to make the test pass
  3. Refactor the code while keeping tests passing

Benefits of TDD:

  • Ensures code is testable from the start
  • Helps identify design issues early
  • Results in higher test coverage
  • Provides documentation of the code's behavior

Testing PayloadCMS Collections

// Example of testing PayloadCMS collection hooks
import { validateUser } from '../collections/Users/hooks/validateUser';

describe('User validation hook', () => {
it('should validate email format', async () => {
const mockData = {
email: 'invalid-email',
name: 'Test User',
};

await expect(validateUser({ data: mockData })).rejects.toThrow(
'Invalid email format'
);
});

it('should transform email to lowercase', async () => {
const mockData = {
email: 'Test@Example.com',
name: 'Test User',
};

const result = await validateUser({ data: mockData });
expect(result.email).toBe('test@example.com');
});
});

Continuous Integration Testing

All tests should be integrated into our CI/CD pipeline:

  1. Unit and integration tests run on every PR
  2. E2E tests run on merges to the dev branch and before production deployment
  3. Test failures block merges to protect code quality

Our GitHub Actions workflow should include:

name: Test Suite

on:
push:
branches: [ main, dev ]
pull_request:
branches: [ main, dev ]

jobs:
test:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v3

- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '20'
cache: 'npm'

- name: Install dependencies
run: npm ci

- name: Lint
run: npm run lint

- name: Unit and integration tests
run: npm test

- name: Build
run: npm run build

- name: E2E tests
run: npm run test:e2e

Testing Improvement Strategy

To improve our testing practices at Kirana Labs, we should:

  1. Add test coverage reports to identify under-tested areas
  2. Implement testing standards in code reviews
  3. Add automated testing to the CI/CD pipeline
  4. Conduct testing workshops for the engineering team
  5. Create testing templates for common components and features

Conclusion

Effective testing is crucial for maintaining code quality and ensuring reliable applications. By following these testing standards, we can create a robust testing culture at Kirana Labs and deliver higher quality software to our clients.

Remember, the goal of testing is not to achieve arbitrary coverage numbers but to have confidence in our code and prevent regressions. Focus on testing the functionality that matters most to users and the business.