Skip to main content

Testing

UniPulse uses Vitest as the test runner for both backend and frontend, with Testing Library for React component tests and Supertest for API endpoint tests.


Testing Stack

ToolPurposePackage
VitestTest runner and assertion libraryvitest
Testing LibraryReact component testing (DOM interaction)@testing-library/react
SupertestHTTP API endpoint testingsupertest
MSWAPI mocking for frontend testsmsw (optional)

Running Tests

# Run all tests across the monorepo
npm run test

# Run tests in watch mode (re-run on file change)
npm run test:watch

# Run tests for a specific package
npm run test --filter=api
npm run test --filter=web
npm run test --filter=shared

# Run a specific test file
npx vitest run apps/api/src/services/__tests__/post.service.test.ts

# Run with coverage
npx vitest run --coverage

Test Organization

Backend Tests

apps/api/src/
├── services/
│ ├── post.service.ts
│ └── __tests__/
│ └── post.service.test.ts
├── routes/
│ ├── post.routes.ts
│ └── __tests__/
│ └── post.routes.test.ts
└── middleware/
├── auth.ts
└── __tests__/
└── auth.test.ts

Frontend Tests

apps/web/src/
├── pages/
│ ├── posts/
│ │ ├── PostList.tsx
│ │ └── __tests__/
│ │ └── PostList.test.tsx
├── components/
│ ├── DataTable.tsx
│ └── __tests__/
│ └── DataTable.test.tsx
└── stores/
├── auth.store.ts
└── __tests__/
└── auth.store.test.ts

Shared Package Tests

packages/shared/src/
├── validators/
│ ├── post.validators.ts
│ └── __tests__/
│ └── post.validators.test.ts

What to Test

Services (Business Logic)

Test CategoryWhat to Verify
Happy pathCorrect output for valid input
Workspace scopingQueries always filter by workspaceId
Not foundThrows NOT_FOUND for invalid IDs
Permission checksThrows FORBIDDEN for unauthorized access
Edge casesEmpty arrays, null fields, max length inputs
Error propagationAppError is thrown, not swallowed
// apps/api/src/services/__tests__/post.service.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { postService } from '../post.service';
import { prisma } from '../../lib/prisma';

vi.mock('../../lib/prisma');

describe('postService', () => {
const workspaceId = 'ws_123';

describe('getAll', () => {
it('returns posts scoped to workspace', async () => {
const mockPosts = [{ id: '1', caption: 'Test', workspaceId }];
vi.mocked(prisma.post.findMany).mockResolvedValue(mockPosts);

const result = await postService.getAll(workspaceId);

expect(prisma.post.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { workspaceId },
})
);
expect(result).toEqual(mockPosts);
});
});

describe('getById', () => {
it('throws NOT_FOUND for non-existent post', async () => {
vi.mocked(prisma.post.findFirst).mockResolvedValue(null);

await expect(postService.getById(workspaceId, 'invalid'))
.rejects.toThrow('NOT_FOUND');
});
});
});

API Routes (Integration)

Test CategoryWhat to Verify
Auth guardReturns 401 without JWT
Role guardReturns 403 with insufficient role
ValidationReturns 400 for invalid request body
Success responseReturns correct status code and body
Error responseReturns structured error format
// apps/api/src/routes/__tests__/post.routes.test.ts
import { describe, it, expect } from 'vitest';
import request from 'supertest';
import { app } from '../../index';

describe('POST /api/v1/posts', () => {
it('returns 401 without auth token', async () => {
const res = await request(app).post('/api/v1/posts').send({});
expect(res.status).toBe(401);
});

it('returns 400 for invalid body', async () => {
const res = await request(app)
.post('/api/v1/posts')
.set('Authorization', `Bearer ${validToken}`)
.send({ caption: '' }); // Missing required fields

expect(res.status).toBe(400);
expect(res.body.error.code).toBe('VALIDATION_ERROR');
});
});

Validators (Schema Correctness)

Test CategoryWhat to Verify
Valid inputSchema parses successfully
Missing required fieldsSchema rejects with appropriate error
Invalid typesSchema rejects wrong data types
Edge casesMin/max length, empty arrays, special characters
// packages/shared/src/validators/__tests__/post.validators.test.ts
import { describe, it, expect } from 'vitest';
import { createPostSchema } from '../post.validators';

describe('createPostSchema', () => {
it('accepts valid input', () => {
const result = createPostSchema.safeParse({
caption: 'Hello world',
platforms: ['FACEBOOK'],
});
expect(result.success).toBe(true);
});

it('rejects empty caption', () => {
const result = createPostSchema.safeParse({
caption: '',
platforms: ['FACEBOOK'],
});
expect(result.success).toBe(false);
});

it('rejects empty platforms array', () => {
const result = createPostSchema.safeParse({
caption: 'Hello',
platforms: [],
});
expect(result.success).toBe(false);
});
});

React Components

Test CategoryWhat to Verify
RenderingComponent renders without crashing
User interactionsClicks, form inputs trigger correct behavior
Loading statesSkeleton/spinner shown during loading
Error statesError message displayed on API failure
Conditional renderingElements shown/hidden based on props/state
// apps/web/src/pages/posts/__tests__/PostList.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { PostList } from '../PostList';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

describe('PostList', () => {
it('shows loading skeleton initially', () => {
render(
<QueryClientProvider client={new QueryClient()}>
<PostList />
</QueryClientProvider>
);
expect(screen.getByTestId('skeleton')).toBeInTheDocument();
});
});

Test Priorities

PriorityWhatWhy
HighService business logicCore correctness
HighZod validatorsShared validation contract
MediumAPI route integration testsAuth + validation + response format
MediumCritical UI flows (auth, composer)User-facing impact
LowerUtility functionsUsually simple and obvious
LowerLayout componentsMinimal logic

Cross-Reference