feat: add comprehensive testing infrastructure
Some checks failed
CI Pipeline / Lint Code (push) Has been cancelled
CI Pipeline / Test API Package (push) Has been cancelled
CI Pipeline / Test Web Package (push) Has been cancelled
CI Pipeline / Test Shared Package (push) Has been cancelled
CI Pipeline / Build All Packages (push) Has been cancelled
CI Pipeline / Generate Coverage Report (push) Has been cancelled
Docker Build & Deploy / Build Docker Images (push) Has been cancelled
Docker Build & Deploy / Push Docker Images (push) Has been cancelled
Docker Build & Deploy / Deploy to Staging (push) Has been cancelled
Docker Build & Deploy / Deploy to Production (push) Has been cancelled
E2E Tests / End-to-End Tests (push) Has been cancelled
E2E Tests / E2E Tests (Mobile) (push) Has been cancelled
Security Scanning / NPM Audit (push) Has been cancelled
Security Scanning / Dependency License Check (push) Has been cancelled
Security Scanning / Code Quality Scan (push) Has been cancelled
Security Scanning / Docker Image Security (push) Has been cancelled
Security Scanning / Security Summary (push) Has been cancelled
Some checks failed
CI Pipeline / Lint Code (push) Has been cancelled
CI Pipeline / Test API Package (push) Has been cancelled
CI Pipeline / Test Web Package (push) Has been cancelled
CI Pipeline / Test Shared Package (push) Has been cancelled
CI Pipeline / Build All Packages (push) Has been cancelled
CI Pipeline / Generate Coverage Report (push) Has been cancelled
Docker Build & Deploy / Build Docker Images (push) Has been cancelled
Docker Build & Deploy / Push Docker Images (push) Has been cancelled
Docker Build & Deploy / Deploy to Staging (push) Has been cancelled
Docker Build & Deploy / Deploy to Production (push) Has been cancelled
E2E Tests / End-to-End Tests (push) Has been cancelled
E2E Tests / E2E Tests (Mobile) (push) Has been cancelled
Security Scanning / NPM Audit (push) Has been cancelled
Security Scanning / Dependency License Check (push) Has been cancelled
Security Scanning / Code Quality Scan (push) Has been cancelled
Security Scanning / Docker Image Security (push) Has been cancelled
Security Scanning / Security Summary (push) Has been cancelled
- Add Vitest for unit testing across all packages - Add Playwright for E2E testing - Add sample tests for API, Web, and Shared packages - Configure Gitea Actions CI/CD workflows (ci, e2e, security, docker) - Add testing documentation (TESTING.md) - Add Gitea Actions setup guide - Update .gitignore for test artifacts - Add test environment configuration
This commit is contained in:
@@ -7,6 +7,10 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"lint": "eslint . --ext ts,tsx"
|
||||
},
|
||||
"keywords": ["basil", "web"],
|
||||
@@ -28,6 +32,13 @@
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.5",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.10"
|
||||
"vite": "^5.0.10",
|
||||
"vitest": "^1.2.0",
|
||||
"@vitest/ui": "^1.2.0",
|
||||
"@vitest/coverage-v8": "^1.2.0",
|
||||
"@testing-library/react": "^14.1.2",
|
||||
"@testing-library/jest-dom": "^6.2.0",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"jsdom": "^23.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
181
packages/web/src/pages/RecipeList.test.tsx
Normal file
181
packages/web/src/pages/RecipeList.test.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import RecipeList from './RecipeList';
|
||||
import { recipesApi } from '../services/api';
|
||||
|
||||
// Mock the API service
|
||||
vi.mock('../services/api', () => ({
|
||||
recipesApi: {
|
||||
getAll: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock useNavigate
|
||||
const mockNavigate = vi.fn();
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom');
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
};
|
||||
});
|
||||
|
||||
describe('RecipeList Component', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const renderWithRouter = (component: React.ReactElement) => {
|
||||
return render(<BrowserRouter>{component}</BrowserRouter>);
|
||||
};
|
||||
|
||||
it('should show loading state initially', () => {
|
||||
vi.mocked(recipesApi.getAll).mockImplementation(
|
||||
() => new Promise(() => {}) // Never resolves
|
||||
);
|
||||
|
||||
renderWithRouter(<RecipeList />);
|
||||
|
||||
expect(screen.getByText('Loading recipes...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display recipes after loading', async () => {
|
||||
const mockRecipes = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Spaghetti Carbonara',
|
||||
description: 'Classic Italian pasta dish',
|
||||
totalTime: 30,
|
||||
servings: 4,
|
||||
imageUrl: '/uploads/recipes/pasta.jpg',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Chocolate Cake',
|
||||
description: 'Rich and moist chocolate cake',
|
||||
totalTime: 60,
|
||||
servings: 8,
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(recipesApi.getAll).mockResolvedValue({
|
||||
data: mockRecipes as any,
|
||||
total: 2,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
});
|
||||
|
||||
renderWithRouter(<RecipeList />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Spaghetti Carbonara')).toBeInTheDocument();
|
||||
expect(screen.getByText('Chocolate Cake')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display empty state when no recipes', async () => {
|
||||
vi.mocked(recipesApi.getAll).mockResolvedValue({
|
||||
data: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
});
|
||||
|
||||
renderWithRouter(<RecipeList />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/No recipes yet. Import one from a URL or create your own!/)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display error message on API failure', async () => {
|
||||
vi.mocked(recipesApi.getAll).mockRejectedValue(new Error('Network error'));
|
||||
|
||||
renderWithRouter(<RecipeList />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Failed to load recipes')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should navigate to recipe detail when card is clicked', async () => {
|
||||
const mockRecipes = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Test Recipe',
|
||||
description: 'Test Description',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(recipesApi.getAll).mockResolvedValue({
|
||||
data: mockRecipes as any,
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
});
|
||||
|
||||
renderWithRouter(<RecipeList />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Recipe')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const recipeCard = screen.getByText('Test Recipe').closest('.recipe-card');
|
||||
recipeCard?.click();
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/recipes/1');
|
||||
});
|
||||
|
||||
it('should display recipe metadata when available', async () => {
|
||||
const mockRecipes = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Recipe with Metadata',
|
||||
totalTime: 45,
|
||||
servings: 6,
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(recipesApi.getAll).mockResolvedValue({
|
||||
data: mockRecipes as any,
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
});
|
||||
|
||||
renderWithRouter(<RecipeList />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('45 min')).toBeInTheDocument();
|
||||
expect(screen.getByText('6 servings')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should truncate long descriptions', async () => {
|
||||
const longDescription = 'A'.repeat(150);
|
||||
const mockRecipes = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Recipe with Long Description',
|
||||
description: longDescription,
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(recipesApi.getAll).mockResolvedValue({
|
||||
data: mockRecipes as any,
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
});
|
||||
|
||||
renderWithRouter(<RecipeList />);
|
||||
|
||||
await waitFor(() => {
|
||||
const description = screen.getByText(/^A{100}\.\.\.$/);
|
||||
expect(description).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
143
packages/web/src/services/api.test.ts
Normal file
143
packages/web/src/services/api.test.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import axios from 'axios';
|
||||
import { recipesApi } from './api';
|
||||
|
||||
vi.mock('axios');
|
||||
|
||||
describe('Recipes API Service', () => {
|
||||
const mockAxios = axios as any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockAxios.create = vi.fn(() => mockAxios);
|
||||
});
|
||||
|
||||
describe('getAll', () => {
|
||||
it('should fetch all recipes with default params', async () => {
|
||||
const mockRecipes = {
|
||||
data: [
|
||||
{ id: '1', title: 'Recipe 1' },
|
||||
{ id: '2', title: 'Recipe 2' },
|
||||
],
|
||||
total: 2,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
};
|
||||
|
||||
mockAxios.get = vi.fn().mockResolvedValue({ data: mockRecipes });
|
||||
|
||||
const result = await recipesApi.getAll();
|
||||
|
||||
expect(mockAxios.get).toHaveBeenCalledWith('/recipes', { params: undefined });
|
||||
expect(result).toEqual(mockRecipes);
|
||||
});
|
||||
|
||||
it('should fetch recipes with search params', async () => {
|
||||
const mockRecipes = {
|
||||
data: [{ id: '1', title: 'Pasta Recipe' }],
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
};
|
||||
|
||||
mockAxios.get = vi.fn().mockResolvedValue({ data: mockRecipes });
|
||||
|
||||
await recipesApi.getAll({ search: 'pasta', page: 1, limit: 10 });
|
||||
|
||||
expect(mockAxios.get).toHaveBeenCalledWith('/recipes', {
|
||||
params: { search: 'pasta', page: 1, limit: 10 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getById', () => {
|
||||
it('should fetch single recipe by id', async () => {
|
||||
const mockRecipe = {
|
||||
data: { id: '1', title: 'Test Recipe', description: 'Test' },
|
||||
};
|
||||
|
||||
mockAxios.get = vi.fn().mockResolvedValue({ data: mockRecipe });
|
||||
|
||||
const result = await recipesApi.getById('1');
|
||||
|
||||
expect(mockAxios.get).toHaveBeenCalledWith('/recipes/1');
|
||||
expect(result).toEqual(mockRecipe);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create new recipe', async () => {
|
||||
const newRecipe = { title: 'New Recipe', description: 'New Description' };
|
||||
const mockResponse = { data: { id: '1', ...newRecipe } };
|
||||
|
||||
mockAxios.post = vi.fn().mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await recipesApi.create(newRecipe);
|
||||
|
||||
expect(mockAxios.post).toHaveBeenCalledWith('/recipes', newRecipe);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should update existing recipe', async () => {
|
||||
const updatedRecipe = { title: 'Updated Recipe' };
|
||||
const mockResponse = { data: { id: '1', ...updatedRecipe } };
|
||||
|
||||
mockAxios.put = vi.fn().mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await recipesApi.update('1', updatedRecipe);
|
||||
|
||||
expect(mockAxios.put).toHaveBeenCalledWith('/recipes/1', updatedRecipe);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should delete recipe', async () => {
|
||||
const mockResponse = { data: { message: 'Recipe deleted' } };
|
||||
|
||||
mockAxios.delete = vi.fn().mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await recipesApi.delete('1');
|
||||
|
||||
expect(mockAxios.delete).toHaveBeenCalledWith('/recipes/1');
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadImage', () => {
|
||||
it('should upload image with multipart form data', async () => {
|
||||
const mockFile = new File(['content'], 'test.jpg', { type: 'image/jpeg' });
|
||||
const mockResponse = { data: { url: '/uploads/recipes/test.jpg' } };
|
||||
|
||||
mockAxios.post = vi.fn().mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await recipesApi.uploadImage('1', mockFile);
|
||||
|
||||
expect(mockAxios.post).toHaveBeenCalledWith(
|
||||
'/recipes/1/images',
|
||||
expect.any(FormData),
|
||||
{ headers: { 'Content-Type': 'multipart/form-data' } }
|
||||
);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('importFromUrl', () => {
|
||||
it('should import recipe from URL', async () => {
|
||||
const url = 'https://example.com/recipe';
|
||||
const mockResponse = {
|
||||
success: true,
|
||||
recipe: { title: 'Imported Recipe' },
|
||||
};
|
||||
|
||||
mockAxios.post = vi.fn().mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await recipesApi.importFromUrl(url);
|
||||
|
||||
expect(mockAxios.post).toHaveBeenCalledWith('/recipes/import', { url });
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
});
|
||||
28
packages/web/vitest.config.ts
Normal file
28
packages/web/vitest.config.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
include: ['src/**/*.{test,spec}.{js,ts,jsx,tsx}'],
|
||||
setupFiles: ['./vitest.setup.ts'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html', 'lcov'],
|
||||
exclude: [
|
||||
'node_modules/',
|
||||
'dist/',
|
||||
'**/*.config.ts',
|
||||
'**/*.d.ts',
|
||||
],
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
});
|
||||
11
packages/web/vitest.setup.ts
Normal file
11
packages/web/vitest.setup.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { expect, afterEach } from 'vitest';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
|
||||
// Cleanup after each test case (e.g., clearing jsdom)
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// Extend Vitest matchers with jest-dom
|
||||
expect.extend({});
|
||||
Reference in New Issue
Block a user