Testing Stack
PC Fix uses modern testing tools:- Vitest: Fast unit and integration tests for both frontend and backend
- Playwright: End-to-end browser testing
- Supertest: HTTP integration testing for API endpoints
- Testing Library: React component testing utilities
All packages include test scripts. Run tests from the monorepo root or individual packages.
Running Tests
Quick Start
- All Tests
- Backend Tests
- Frontend Tests
- E2E Tests
Run tests across the entire monorepo:
npm test
Test API endpoints and services:
cd packages/api
npm test
Test React components and utilities:
cd packages/web
npm test
Run Playwright browser tests:
cd packages/web
npm run e2e
Test Modes
# Run tests in watch mode (re-run on file changes)
npm test -- --watch
Backend Testing (API)
Unit Tests Example
src/modules/auth/auth.service.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { AuthService } from './auth.service';
import { prisma } from '../../shared/database/prismaClient';
import bcrypt from 'bcryptjs';
vi.mock('../../shared/database/prismaClient');
vi.mock('bcryptjs');
describe('AuthService', () => {
let authService: AuthService;
beforeEach(() => {
authService = new AuthService();
vi.clearAllMocks();
});
describe('register', () => {
it('should create a new user with hashed password', async () => {
const userData = {
email: '[email protected]',
nombre: 'Test',
apellido: 'User',
password: 'password123',
};
vi.mocked(bcrypt.hash).mockResolvedValue('hashedPassword123' as never);
vi.mocked(prisma.user.create).mockResolvedValue({
id: 1,
...userData,
password: 'hashedPassword123',
role: 'USER',
createdAt: new Date(),
updatedAt: new Date(),
} as any);
const result = await authService.register(userData);
expect(bcrypt.hash).toHaveBeenCalledWith('password123', 10);
expect(prisma.user.create).toHaveBeenCalledWith({
data: {
...userData,
password: 'hashedPassword123',
},
});
expect(result.email).toBe('[email protected]');
});
it('should throw error if email already exists', async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({ id: 1 } as any);
await expect(
authService.register({
email: '[email protected]',
nombre: 'Test',
apellido: 'User',
password: 'password123',
})
).rejects.toThrow('Email already in use');
});
});
});
Integration Tests Example
src/modules/products/products.routes.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import app from '../../server';
import { prisma } from '../../shared/database/prismaClient';
describe('Products API', () => {
let adminToken: string;
let testProductId: number;
beforeAll(async () => {
// Login as admin to get token
const response = await request(app)
.post('/api/auth/login')
.send({
email: '[email protected]',
password: 'admin123',
});
adminToken = response.body.token;
});
afterAll(async () => {
// Cleanup test data
await prisma.producto.deleteMany({
where: { nombre: { contains: 'TEST_' } },
});
await prisma.$disconnect();
});
describe('GET /api/products', () => {
it('should return paginated products', async () => {
const response = await request(app)
.get('/api/products')
.query({ page: 1, limit: 10 });
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('products');
expect(response.body).toHaveProperty('pagination');
expect(Array.isArray(response.body.products)).toBe(true);
});
it('should filter products by category', async () => {
const response = await request(app)
.get('/api/products')
.query({ categoryId: 1 });
expect(response.status).toBe(200);
response.body.products.forEach((product: any) => {
expect(product.categoriaId).toBe(1);
});
});
});
describe('POST /api/products', () => {
it('should create a new product (admin only)', async () => {
const newProduct = {
nombre: 'TEST_RTX_4090',
descripcion: 'Test graphics card',
precio: 1999.99,
stock: 5,
categoriaId: 1,
marcaId: 1,
};
const response = await request(app)
.post('/api/products')
.set('Authorization', `Bearer ${adminToken}`)
.send(newProduct);
expect(response.status).toBe(201);
expect(response.body.nombre).toBe('TEST_RTX_4090');
expect(response.body.precio).toBe(1999.99);
testProductId = response.body.id;
});
it('should reject unauthorized requests', async () => {
const response = await request(app)
.post('/api/products')
.send({ nombre: 'Unauthorized Product' });
expect(response.status).toBe(401);
});
});
describe('PUT /api/products/:id', () => {
it('should update product (admin only)', async () => {
const updates = { precio: 1899.99, stock: 10 };
const response = await request(app)
.put(`/api/products/${testProductId}`)
.set('Authorization', `Bearer ${adminToken}`)
.send(updates);
expect(response.status).toBe(200);
expect(response.body.precio).toBe(1899.99);
expect(response.body.stock).toBe(10);
});
});
});
Frontend Testing (Web)
Component Tests Example
src/components/store/ProductCard.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { ProductCard } from './ProductCard';
import { useCartStore } from '../../stores/cartStore';
vi.mock('../../stores/cartStore');
describe('ProductCard', () => {
const mockProduct = {
id: '1',
nombre: 'RTX 4090',
descripcion: 'NVIDIA GeForce RTX 4090',
precio: 2499.99,
stock: 5,
imagenes: [{ url: '/test-image.jpg' }],
};
beforeEach(() => {
vi.mocked(useCartStore).mockReturnValue({
addItem: vi.fn(),
items: [],
} as any);
});
it('should render product information', () => {
render(<ProductCard product={mockProduct} />);
expect(screen.getByText('RTX 4090')).toBeInTheDocument();
expect(screen.getByText('$2,499.99')).toBeInTheDocument();
expect(screen.getByText('5 in stock')).toBeInTheDocument();
});
it('should call addItem when Add to Cart clicked', () => {
const mockAddItem = vi.fn();
vi.mocked(useCartStore).mockReturnValue({
addItem: mockAddItem,
items: [],
} as any);
render(<ProductCard product={mockProduct} />);
const addButton = screen.getByRole('button', { name: /add to cart/i });
fireEvent.click(addButton);
expect(mockAddItem).toHaveBeenCalledWith({
productoId: '1',
quantity: 1,
});
});
it('should show out of stock message when stock is 0', () => {
render(<ProductCard product={{ ...mockProduct, stock: 0 }} />);
expect(screen.getByText(/out of stock/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /add to cart/i })).toBeDisabled();
});
});
Store Tests Example
src/stores/cartStore.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useCartStore } from './cartStore';
describe('CartStore', () => {
beforeEach(() => {
// Reset store before each test
useCartStore.getState().clearCart();
});
it('should add item to cart', () => {
const { result } = renderHook(() => useCartStore());
act(() => {
result.current.addItem({ productoId: '1', quantity: 2 });
});
expect(result.current.items).toHaveLength(1);
expect(result.current.items[0].productoId).toBe('1');
expect(result.current.items[0].quantity).toBe(2);
});
it('should increase quantity if item already in cart', () => {
const { result } = renderHook(() => useCartStore());
act(() => {
result.current.addItem({ productoId: '1', quantity: 2 });
result.current.addItem({ productoId: '1', quantity: 3 });
});
expect(result.current.items).toHaveLength(1);
expect(result.current.items[0].quantity).toBe(5);
});
it('should calculate total price correctly', () => {
const { result } = renderHook(() => useCartStore());
act(() => {
result.current.addItem({ productoId: '1', quantity: 2, precio: 100 });
result.current.addItem({ productoId: '2', quantity: 1, precio: 50 });
});
expect(result.current.total).toBe(250);
});
it('should remove item from cart', () => {
const { result } = renderHook(() => useCartStore());
act(() => {
result.current.addItem({ productoId: '1', quantity: 2 });
result.current.removeItem('1');
});
expect(result.current.items).toHaveLength(0);
});
});
End-to-End Testing (Playwright)
E2E Test Example
tests/e2e/checkout.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Checkout Flow', () => {
test.beforeEach(async ({ page }) => {
// Navigate to store
await page.goto('http://localhost:4321');
});
test('should complete full checkout process', async ({ page }) => {
// Add product to cart
await page.click('[data-testid="product-card-1"]');
await page.click('[data-testid="add-to-cart"]');
// Verify cart badge updated
await expect(page.locator('[data-testid="cart-badge"]')).toHaveText('1');
// Go to cart
await page.click('[data-testid="cart-icon"]');
await expect(page).toHaveURL(/.*\/checkout/);
// Fill shipping information
await page.fill('[name="nombre"]', 'John');
await page.fill('[name="apellido"]', 'Doe');
await page.fill('[name="email"]', '[email protected]');
await page.fill('[name="direccion"]', '123 Main St');
await page.fill('[name="telefono"]', '1234567890');
// Select payment method
await page.click('[data-testid="payment-method-mercadopago"]');
// Submit order
await page.click('[data-testid="submit-order"]');
// Verify success
await expect(page.locator('[data-testid="order-confirmation"]')).toBeVisible();
await expect(page.locator('[data-testid="order-number"]')).toBeVisible();
});
test('should validate required fields', async ({ page }) => {
// Add product and go to checkout
await page.click('[data-testid="product-card-1"]');
await page.click('[data-testid="add-to-cart"]');
await page.click('[data-testid="cart-icon"]');
// Try to submit without filling required fields
await page.click('[data-testid="submit-order"]');
// Check for validation errors
await expect(page.locator('text=Email is required')).toBeVisible();
await expect(page.locator('text=Phone is required')).toBeVisible();
});
test('should handle out of stock products', async ({ page }) => {
// Navigate to out-of-stock product
await page.goto('http://localhost:4321/producto/out-of-stock-item');
// Verify add to cart button is disabled
const addButton = page.locator('[data-testid="add-to-cart"]');
await expect(addButton).toBeDisabled();
await expect(page.locator('text=Out of Stock')).toBeVisible();
});
});
Playwright Configuration
playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:4321',
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:4321',
reuseExistingServer: !process.env.CI,
},
});
Test Coverage
Generate coverage reports:# Backend coverage
cd packages/api
npm test -- --coverage
# Frontend coverage
cd packages/web
npm test -- --coverage
coverage/ directories.
Aim for at least 80% code coverage for critical paths like authentication, payment processing, and inventory management.
CI/CD Integration
Add tests to your CI pipeline:.github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test-api:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: postgres
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 20
- name: Install dependencies
run: npm install
working-directory: packages/api
- name: Run tests
run: npm test -- --coverage
working-directory: packages/api
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test_db
- name: Upload coverage
uses: codecov/codecov-action@v3
test-web:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 20
- name: Install dependencies
run: npm install
working-directory: packages/web
- name: Run tests
run: npm test -- --coverage
working-directory: packages/web
- name: Upload coverage
uses: codecov/codecov-action@v3
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 20
- name: Install dependencies
run: npm install
- name: Install Playwright
run: npx playwright install --with-deps
- name: Run E2E tests
run: npm run e2e
working-directory: packages/web
- uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-report
path: packages/web/playwright-report/
Best Practices
Write Tests First (TDD)
Write failing tests before implementing features:
# 1. Write test
npm test -- --watch auth.test.ts
# 2. Implement feature until test passes
# 3. Refactor
Test User Workflows
Focus on user-facing functionality, not implementation details:
// Good: Test user behavior
it('should allow user to login with valid credentials', async () => {});
// Bad: Test implementation
it('should call bcrypt.compare with correct arguments', async () => {});
Use Realistic Test Data
Use realistic data that matches production patterns:
const testUser = {
email: '[email protected]',
nombre: 'John',
apellido: 'Doe',
telefono: '+54 9 11 1234-5678',
};
Troubleshooting
Tests timing out
Tests timing out
Increase test timeout:
// vitest.config.ts
export default defineConfig({
test: {
testTimeout: 30000, // 30 seconds
},
});
Database connection issues in tests
Database connection issues in tests
Use separate test database:
# .env.test
DATABASE_URL="postgresql://admin:password123@localhost:5432/pcfix_test"
Playwright browser launch failed
Playwright browser launch failed
Install browsers:
npx playwright install
Mock not working
Mock not working
Ensure mocks are defined before imports:
vi.mock('./module', () => ({ ... }));
import { moduleFunction } from './module';
Next Steps
Backend Architecture
Understand the API structure
Frontend Architecture
Learn about React components
CI/CD
View CI/CD pipelines
Contributing
Contribute to the project