Testing Philosophy
Our testing approach:Test Behavior
Test what users experience, not implementation details
Test Pyramid
70% unit, 20% integration, 10% E2E tests
Fast Feedback
Prefer fast unit tests over slow integration tests
Maintainable
Write tests that are easy to understand and maintain
Running Tests
Quick Commands
# Run a single test file (fastest - preferred)
npx jest path/to/test.test.ts --config=packages/twenty-front/jest.config.mjs
# Run all tests for a package
npx nx test twenty-front
npx nx test twenty-server
# Run integration tests with database reset
npx nx run twenty-server:test:integration:with-db-reset
# Run tests in watch mode
npx nx test twenty-front --watch
# Run tests with coverage
npx nx test twenty-front --coverage
Run Tests by Pattern
# In specific workspace
cd packages/twenty-front
npx jest "user"
# All tests matching pattern
cd packages/twenty-server
npx jest "user.service"
Unit Testing
Frontend Unit Tests
Test React components using Testing Library:import { render, screen, fireEvent } from '@testing-library/react';
import { Button } from '../button.component';
describe('Button', () => {
it('should render button with label', () => {
render(<Button label="Click me" onClick={() => {}} />);
expect(screen.getByText('Click me')).toBeInTheDocument();
});
it('should call onClick when clicked', () => {
const handleClick = jest.fn();
render(<Button label="Click me" onClick={handleClick} />);
fireEvent.click(screen.getByText('Click me'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('should be disabled when disabled prop is true', () => {
render(<Button label="Click me" onClick={() => {}} disabled />);
expect(screen.getByRole('button')).toBeDisabled();
});
});
Testing Hooks
import { renderHook, act } from '@testing-library/react';
import { useCounter } from '../use-counter.hook';
describe('useCounter', () => {
it('should initialize with default value', () => {
const { result } = renderHook(() => useCounter(0));
expect(result.current.count).toBe(0);
});
it('should increment count', () => {
const { result } = renderHook(() => useCounter(0));
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it('should decrement count', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(4);
});
});
Backend Unit Tests
Test NestJS services:import { Test, TestingModule } from '@nestjs/testing';
import { UserService } from '../user.service';
import { Repository } from 'typeorm';
import { UserEntity } from '../user.entity';
import { getRepositoryToken } from '@nestjs/typeorm';
describe('UserService', () => {
let service: UserService;
let repository: Repository<UserEntity>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
UserService,
{
provide: getRepositoryToken(UserEntity),
useValue: {
findOne: jest.fn(),
save: jest.fn(),
delete: jest.fn(),
},
},
],
}).compile();
service = module.get<UserService>(UserService);
repository = module.get<Repository<UserEntity>>(
getRepositoryToken(UserEntity),
);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('findById', () => {
it('should return user when found', async () => {
const mockUser = {
id: 'user-id',
firstName: 'John',
lastName: 'Doe',
email: '[email protected]',
};
jest.spyOn(repository, 'findOne').mockResolvedValue(mockUser as any);
const result = await service.findById('user-id');
expect(result).toEqual(mockUser);
expect(repository.findOne).toHaveBeenCalledWith({
where: { id: 'user-id' },
});
});
it('should throw NotFoundException when user not found', async () => {
jest.spyOn(repository, 'findOne').mockResolvedValue(null);
await expect(service.findById('invalid-id')).rejects.toThrow(
NotFoundException,
);
});
});
describe('create', () => {
it('should create and return new user', async () => {
const createUserDto = {
firstName: 'Jane',
lastName: 'Smith',
email: '[email protected]',
};
const savedUser = {
id: 'new-user-id',
...createUserDto,
};
jest.spyOn(repository, 'save').mockResolvedValue(savedUser as any);
const result = await service.create(createUserDto);
expect(result).toEqual(savedUser);
expect(repository.save).toHaveBeenCalledWith(createUserDto);
});
});
});
Integration Testing
GraphQL Integration Tests
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../app.module';
describe('User GraphQL Integration', () => {
let app: INestApplication;
let userId: string;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
afterAll(async () => {
await app.close();
});
describe('createUser mutation', () => {
it('should create a new user', async () => {
const mutation = `
mutation CreateUser($data: CreateUserInput!) {
createUser(data: $data) {
id
firstName
lastName
email
}
}
`;
const variables = {
data: {
firstName: 'John',
lastName: 'Doe',
email: '[email protected]',
},
};
const response = await request(app.getHttpServer())
.post('/graphql')
.send({ query: mutation, variables })
.expect(200);
expect(response.body.data.createUser).toMatchObject({
firstName: 'John',
lastName: 'Doe',
email: '[email protected]',
});
userId = response.body.data.createUser.id;
});
});
describe('user query', () => {
it('should fetch user by id', async () => {
const query = `
query GetUser($id: UUID!) {
user(id: $id) {
id
firstName
email
}
}
`;
const response = await request(app.getHttpServer())
.post('/graphql')
.send({ query, variables: { id: userId } })
.expect(200);
expect(response.body.data.user).toMatchObject({
id: userId,
firstName: 'John',
email: '[email protected]',
});
});
});
});
Database Integration Tests
import { Test, TestingModule } from '@nestjs/testing';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserService } from '../user.service';
import { UserEntity } from '../user.entity';
import { Repository } from 'typeorm';
describe('UserService Integration', () => {
let service: UserService;
let repository: Repository<UserEntity>;
let module: TestingModule;
beforeAll(async () => {
module = await Test.createTestingModule({
imports: [
TypeOrmModule.forRoot({
type: 'postgres',
host: 'localhost',
port: 5432,
username: 'test',
password: 'test',
database: 'test',
entities: [UserEntity],
synchronize: true,
}),
TypeOrmModule.forFeature([UserEntity]),
],
providers: [UserService],
}).compile();
service = module.get<UserService>(UserService);
repository = module.get<Repository<UserEntity>>(
getRepositoryToken(UserEntity),
);
});
afterAll(async () => {
await repository.clear();
await module.close();
});
afterEach(async () => {
await repository.clear();
});
it('should create and retrieve user', async () => {
const userData = {
firstName: 'John',
lastName: 'Doe',
email: '[email protected]',
};
const created = await service.create(userData);
expect(created.id).toBeDefined();
const found = await service.findById(created.id);
expect(found).toMatchObject(userData);
});
it('should update user', async () => {
const user = await service.create({
firstName: 'John',
lastName: 'Doe',
email: '[email protected]',
});
const updated = await service.update(user.id, {
firstName: 'Jane',
});
expect(updated.firstName).toBe('Jane');
expect(updated.lastName).toBe('Doe');
});
});
E2E Testing
Playwright tests for end-to-end scenarios:import { test, expect } from '@playwright/test';
test.describe('User Management', () => {
test.beforeEach(async ({ page }) => {
await page.goto('http://localhost:3001');
// Login if needed
await page.click('text=Continue with Email');
});
test('should create a new person', async ({ page }) => {
// Navigate to people
await page.click('[data-testid="nav-people"]');
// Click add button
await page.click('[data-testid="add-person"]');
// Fill form
await page.fill('[name="firstName"]', 'John');
await page.fill('[name="lastName"]', 'Doe');
await page.fill('[name="email"]', '[email protected]');
// Submit
await page.click('button[type="submit"]');
// Verify created
await expect(page.locator('text=John Doe')).toBeVisible();
});
test('should edit person details', async ({ page }) => {
// Open person
await page.click('text=John Doe');
// Edit job title
await page.click('[data-field="jobTitle"]');
await page.fill('input[name="jobTitle"]', 'Software Engineer');
await page.press('input[name="jobTitle"]', 'Enter');
// Verify updated
await expect(page.locator('text=Software Engineer')).toBeVisible();
});
});
Testing Best Practices
Test Naming
Use descriptive test names:// Good
it('should return user when valid ID is provided', () => {});
it('should throw NotFoundException when user does not exist', () => {});
it('should validate email format before creating user', () => {});
// Bad
it('works', () => {});
it('test user', () => {});
it('should work correctly', () => {});
Arrange-Act-Assert
Structure tests clearly:it('should increment counter', () => {
// Arrange
const initialCount = 0;
const { result } = renderHook(() => useCounter(initialCount));
// Act
act(() => {
result.current.increment();
});
// Assert
expect(result.current.count).toBe(1);
});
Query by User-Visible Elements
// Good - query by text, role, label
screen.getByText('Submit');
screen.getByRole('button', { name: 'Submit' });
screen.getByLabelText('Email');
// Bad - query by test ID (only when necessary)
screen.getByTestId('submit-button');
Use Realistic Interactions
import userEvent from '@testing-library/user-event';
// Good - realistic user interaction
await userEvent.click(button);
await userEvent.type(input, '[email protected]');
// Bad - synthetic events
fireEvent.click(button);
fireEvent.change(input, { target: { value: '[email protected]' } });
Clean Up Mocks
describe('UserService', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('should call repository', () => {
// Test implementation
});
});
Test Error Cases
describe('UserService.findById', () => {
it('should return user when found', async () => {
// Happy path test
});
it('should throw NotFoundException when not found', async () => {
jest.spyOn(repository, 'findOne').mockResolvedValue(null);
await expect(service.findById('invalid')).rejects.toThrow(
NotFoundException,
);
});
it('should handle database errors', async () => {
jest.spyOn(repository, 'findOne').mockRejectedValue(new Error('DB Error'));
await expect(service.findById('id')).rejects.toThrow();
});
});
Mocking
Mock External Dependencies
import { ExternalApiService } from '../external-api.service';
jest.mock('../external-api.service');
const mockExternalApi = ExternalApiService as jest.MockedClass<
typeof ExternalApiService
>;
beforeEach(() => {
mockExternalApi.prototype.fetchData.mockResolvedValue({ data: 'test' });
});
Mock GraphQL Client
import { MockedProvider } from '@apollo/client/testing';
const mocks = [
{
request: {
query: GET_USER_QUERY,
variables: { id: 'user-id' },
},
result: {
data: {
user: {
id: 'user-id',
firstName: 'John',
lastName: 'Doe',
},
},
},
},
];
render(
<MockedProvider mocks={mocks}>
<UserProfile userId="user-id" />
</MockedProvider>,
);
Storybook
Test UI components visually:import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './button.component';
const meta: Meta<typeof Button> = {
component: Button,
title: 'UI/Button',
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<typeof Button>;
export const Primary: Story = {
args: {
label: 'Click me',
variant: 'primary',
onClick: () => console.log('Clicked'),
},
};
export const Secondary: Story = {
args: {
label: 'Click me',
variant: 'secondary',
},
};
export const Disabled: Story = {
args: {
label: 'Disabled',
disabled: true,
},
};
Run Storybook
# Start Storybook
npx nx storybook:build twenty-front
# Test Storybook
npx nx storybook:test twenty-front
Coverage
Check test coverage:# Run with coverage
npx nx test twenty-front --coverage
npx nx test twenty-server --coverage
# View coverage report
open packages/twenty-front/coverage/lcov-report/index.html
Coverage Goals
- Statements: > 80%
- Branches: > 75%
- Functions: > 80%
- Lines: > 80%
CI/CD Testing
Tests run automatically in CI:- On PR - All tests must pass
- On commit - Linting and type checking
- Pre-merge - Full test suite
Local CI Testing
Run the same checks as CI:# Lint
npx nx lint:diff-with-main twenty-front
npx nx lint:diff-with-main twenty-server
# Type check
npx nx typecheck twenty-front
npx nx typecheck twenty-server
# Test
npx nx test twenty-front
npx nx test twenty-server
# Integration tests
npx nx run twenty-server:test:integration:with-db-reset
Debugging Tests
Debug in VS Code
Add to.vscode/launch.json:
{
"type": "node",
"request": "launch",
"name": "Jest Debug",
"program": "${workspaceFolder}/node_modules/.bin/jest",
"args": [
"--runInBand",
"--no-cache",
"--watchAll=false",
"${file}"
],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen"
}
Debug Single Test
// Add .only to run single test
it.only('should do something', () => {
// Test code
});
Console Debugging
import { screen, debug } from '@testing-library/react';
// Print DOM
debug();
// Print specific element
debug(screen.getByRole('button'));
// Log component state
console.log(result.current);
Common Testing Patterns
Testing Async Operations
it('should load user data', async () => {
render(<UserProfile userId="user-id" />);
// Wait for loading to finish
await waitFor(() => {
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});
// Verify data loaded
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
Testing Forms
it('should submit form with valid data', async () => {
const onSubmit = jest.fn();
render(<UserForm onSubmit={onSubmit} />);
await userEvent.type(screen.getByLabelText('First Name'), 'John');
await userEvent.type(screen.getByLabelText('Last Name'), 'Doe');
await userEvent.type(screen.getByLabelText('Email'), '[email protected]');
await userEvent.click(screen.getByRole('button', { name: 'Submit' }));
expect(onSubmit).toHaveBeenCalledWith({
firstName: 'John',
lastName: 'Doe',
email: '[email protected]',
});
});
Next Steps
Code Style
Follow code conventions
Getting Started
Start contributing
Local Setup
Set up dev environment
Architecture
Understand the codebase
