Comprehensive testing documentation for Crypto Data Aggregator.
The project uses Vitest as the test runner with React Testing Library for component testing.
Test Files: src/**/*.test.ts, src/**/*.spec.ts
Environment: jsdom
Coverage: V8 provider
| Tool | Purpose |
|---|---|
| Vitest | Test runner, assertions |
| Testing Library | Component testing |
| jsdom | Browser environment simulation |
| V8 Coverage | Code coverage |
| Playwright | E2E testing |
# Run all tests once
npm run test:run
# Run tests in watch mode (development)
npm run test:watch
# Run with interactive UI
npm run test:ui
# Run with coverage report
npm run test:coverage
# Run specific test file
npx vitest src/lib/portfolio.test.ts
# Run tests matching pattern
npx vitest --grep "portfolio"When running npm run test:watch:
| Key | Action |
|---|---|
a |
Run all tests |
f |
Run only failed tests |
p |
Filter by filename |
t |
Filter by test name |
q |
Quit |
Test API endpoints interactively using the built-in Swagger UI:
Development: http://localhost:3000/docs/swagger
Production: https://cryptonews.direct/docs/swagger
Features:
- Try It Out - Execute requests directly
- Response examples - See expected response formats
- Authentication - Test with API keys
- Rate limit headers - View remaining quota
Import the OpenAPI 3.1 spec into your API testing tool:
# Download OpenAPI spec
curl https://cryptonews.direct/api/v2/openapi.json > openapi.json
# Test with curl
curl -X GET "https://cryptonews.direct/api/v2/news?limit=5"# Run Playwright E2E tests
npm run test:e2e
# Run specific API tests
npx playwright test e2e/api.spec.tssrc/lib/portfolio.ts # Source file
src/lib/portfolio.test.ts # Test file
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { calculatePortfolioValue, addHolding } from './portfolio';
describe('Portfolio', () => {
beforeEach(() => {
// Setup before each test
localStorage.clear();
});
afterEach(() => {
// Cleanup after each test
vi.restoreAllMocks();
});
describe('calculatePortfolioValue', () => {
it('should calculate total value correctly', () => {
const holdings = [
{ coinId: 'bitcoin', amount: 1, currentPrice: 50000 },
{ coinId: 'ethereum', amount: 10, currentPrice: 3000 },
];
const result = calculatePortfolioValue(holdings);
expect(result.totalValue).toBe(80000);
});
it('should handle empty portfolio', () => {
const result = calculatePortfolioValue([]);
expect(result.totalValue).toBe(0);
});
});
});import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { PriceAlertButton } from '@/components/alerts/PriceAlertButton';
describe('PriceAlertButton', () => {
it('should render alert button', () => {
render(<PriceAlertButton coinId="bitcoin" coinName="Bitcoin" />);
expect(screen.getByRole('button')).toBeInTheDocument();
});
it('should open modal on click', async () => {
render(<PriceAlertButton coinId="bitcoin" coinName="Bitcoin" />);
fireEvent.click(screen.getByRole('button'));
await waitFor(() => {
expect(screen.getByText(/create alert/i)).toBeInTheDocument();
});
});
});import { describe, it, expect, vi } from 'vitest';
import { getTopCoins } from './market-data';
describe('Market Data', () => {
it('should fetch top coins', async () => {
const coins = await getTopCoins({ limit: 10 });
expect(coins).toHaveLength(10);
expect(coins[0]).toHaveProperty('id');
expect(coins[0]).toHaveProperty('current_price');
});
it('should handle API errors gracefully', async () => {
vi.spyOn(global, 'fetch').mockRejectedValueOnce(new Error('Network error'));
await expect(getTopCoins()).rejects.toThrow('Network error');
});
});import { vi } from 'vitest';
// Mock entire module
vi.mock('@/lib/market-data', () => ({
getTopCoins: vi
.fn()
.mockResolvedValue([{ id: 'bitcoin', name: 'Bitcoin', current_price: 50000 }]),
getCoinDetails: vi.fn(),
}));
// Mock with implementation
vi.mock('@/lib/cache', () => ({
newsCache: {
get: vi.fn(),
set: vi.fn(),
has: vi.fn().mockReturnValue(false),
},
}));import { vi, beforeEach, afterEach } from 'vitest';
beforeEach(() => {
global.fetch = vi.fn();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should fetch data', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: 'test' }),
} as Response);
const result = await fetchData();
expect(fetch).toHaveBeenCalledWith('/api/data');
expect(result).toEqual({ data: 'test' });
});import { vi } from 'vitest';
const localStorageMock = {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
};
Object.defineProperty(window, 'localStorage', {
value: localStorageMock,
});
it('should save to localStorage', () => {
saveData('key', { value: 1 });
expect(localStorageMock.setItem).toHaveBeenCalledWith('key', JSON.stringify({ value: 1 }));
});import { vi, beforeEach, afterEach } from 'vitest';
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should debounce calls', async () => {
const callback = vi.fn();
const debounced = debounce(callback, 1000);
debounced();
debounced();
debounced();
expect(callback).not.toHaveBeenCalled();
vi.advanceTimersByTime(1000);
expect(callback).toHaveBeenCalledTimes(1);
});npm run test:coverage % Coverage report from v8
-----------------------|---------|----------|---------|---------|
File | % Stmts | % Branch | % Funcs | % Lines |
-----------------------|---------|----------|---------|---------|
All files | 85.23 | 78.45 | 82.10 | 85.23 |
src/lib/ | 92.15 | 88.30 | 90.00 | 92.15 |
alerts.ts | 88.50 | 82.00 | 85.00 | 88.50 |
cache.ts | 95.00 | 90.00 | 95.00 | 95.00 |
market-data.ts | 90.00 | 85.00 | 88.00 | 90.00 |
portfolio.ts | 98.00 | 95.00 | 100.0 | 98.00 |
-----------------------|---------|----------|---------|---------|
Add to vitest.config.ts:
coverage: {
provider: 'v8',
thresholds: {
lines: 80,
functions: 80,
branches: 70,
statements: 80,
},
},npm run test:coverage
open coverage/index.html# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm run test:run
- name: Run coverage
run: npm run test:coverage
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
files: ./coverage/lcov.infoThe project uses Husky for pre-commit hooks:
# Runs automatically before each commit
npm run test:run
npm run lint
npm run typecheck// ❌ Bad: Testing implementation details
expect(component.state.isOpen).toBe(true);
// ✅ Good: Testing user-visible behavior
expect(screen.getByRole('dialog')).toBeVisible();// ❌ Bad
it('works', () => { ... });
// ✅ Good
it('should display error message when API call fails', () => { ... });it('should add holding to portfolio', () => {
// Arrange
const portfolio = createPortfolio('user_1', 'My Portfolio');
const holding = { coinId: 'bitcoin', amount: 1 };
// Act
addHolding(portfolio.id, holding);
// Assert
const updated = getPortfolio(portfolio.id);
expect(updated.holdings).toContainEqual(expect.objectContaining(holding));
});// ❌ Bad: Multiple unrelated assertions
it('should work', () => {
expect(result.value).toBe(100);
expect(result.status).toBe('success');
expect(result.timestamp).toBeDefined();
});
// ✅ Good: Focused tests
it('should calculate correct value', () => {
expect(result.value).toBe(100);
});
it('should return success status', () => {
expect(result.status).toBe('success');
});afterEach(() => {
localStorage.clear();
vi.restoreAllMocks();
cleanup(); // React Testing Library
});npx vitest src/lib/portfolio.test.ts -t "should add holding"npx vitest --inspect-brk --single-threadThen attach VS Code debugger.
{
"type": "node",
"request": "launch",
"name": "Debug Vitest",
"program": "${workspaceFolder}/node_modules/vitest/vitest.mjs",
"args": ["run", "--single-thread", "--no-file-parallelism"],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal"
}