feat(tests): Jest backend (38 tests), Jest RTL frontend (28 tests), Playwright E2E spec

This commit is contained in:
Experiments DB Dev
2026-04-15 13:23:48 -04:00
parent c359cb4bb9
commit 5874ed8374
25 changed files with 9620 additions and 3652 deletions
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
presets: [
['@babel/preset-env', { targets: { node: 'current' } }],
['@babel/preset-react', { runtime: 'automatic' }],
],
};
+105
View File
@@ -0,0 +1,105 @@
// @ts-check
const { test, expect } = require('@playwright/test');
/**
* E2E tests run against the full Docker stack (frontend on port 52867,
* backend on port 3001). To run locally:
* docker-compose up -d
* npx playwright test
*/
const BASE_URL = process.env.E2E_BASE_URL || 'http://localhost:52867';
test.describe('UI Flow — create experiment → add animal → log status', () => {
test('full happy path', async ({ page }) => {
await page.goto(BASE_URL);
// ── Create experiment ────────────────────────────────────────
await page.click('button:has-text("New Experiment")');
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByLabel(/experiment title/i).fill('E2E Test Study');
await page.click('button:has-text("Create Experiment")');
// Modal closes and new experiment appears in the list
await expect(page.getByRole('dialog')).not.toBeVisible();
await expect(page.getByText('E2E Test Study')).toBeVisible();
// ── Navigate into experiment ──────────────────────────────────
await page.click('text=E2E Test Study');
await expect(page.url()).toContain('/experiments/');
// ── Add animal ────────────────────────────────────────────────
await page.click('button:has-text("Add Animal")');
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByLabel(/animal id/i).fill('M-E2E-001');
await page.getByLabel(/animal name/i).fill('TestMouse');
await page.click('button:has-text("Add Animal")');
await expect(page.getByRole('dialog')).not.toBeVisible();
await expect(page.getByText('TestMouse')).toBeVisible();
// ── Navigate into animal ──────────────────────────────────────
await page.click('text=TestMouse');
await expect(page.url()).toContain('/animals/');
// ── Log daily status ──────────────────────────────────────────
await page.click('button:has-text("Log Daily Status")');
await expect(page.getByRole('dialog')).toBeVisible();
// Date is pre-filled with today; add vitals
await page.getByLabel(/vitals/i).fill('HR 70, Temp 37.0');
await page.getByLabel(/treatment/i).fill('Control');
await page.click('button:has-text("Log Status")');
await expect(page.getByRole('dialog')).not.toBeVisible();
await expect(page.getByText('HR 70, Temp 37.0')).toBeVisible();
});
});
test.describe('UX Flow — validation error states', () => {
test('empty experiment title shows error', async ({ page }) => {
await page.goto(BASE_URL);
await page.click('button:has-text("New Experiment")');
await page.click('button:has-text("Create Experiment")');
await expect(page.getByRole('alert')).toContainText(/title is required/i);
});
test('empty animal fields show errors', async ({ page }) => {
await page.goto(BASE_URL);
// Need an experiment to navigate into — create one first
await page.click('button:has-text("New Experiment")');
await page.getByLabel(/experiment title/i).fill('UX Test Study');
await page.click('button:has-text("Create Experiment")');
await page.click('text=UX Test Study');
await page.click('button:has-text("Add Animal")');
await page.click('button:has-text("Add Animal")'); // submit without filling
const alerts = page.getByRole('alert');
await expect(alerts).toHaveCount(2); // ID + Name errors
});
test('missing date on daily status shows error', async ({ page }) => {
await page.goto(BASE_URL);
// Create experiment + animal to get to daily status form
await page.click('button:has-text("New Experiment")');
await page.getByLabel(/experiment title/i).fill('Date Test Study');
await page.click('button:has-text("Create Experiment")');
await page.click('text=Date Test Study');
await page.click('button:has-text("Add Animal")');
await page.getByLabel(/animal id/i).fill('M-DT-001');
await page.getByLabel(/animal name/i).fill('DateTestMouse');
await page.click('button:has-text("Add Animal")');
await page.click('text=DateTestMouse');
await page.click('button:has-text("Log Daily Status")');
// Clear the date field
await page.getByLabel(/date/i).fill('');
await page.click('button:has-text("Log Status")');
await expect(page.getByRole('alert')).toContainText(/date is required/i);
});
});
+8561 -3581
View File
File diff suppressed because it is too large Load Diff
+22 -10
View File
@@ -6,9 +6,8 @@
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test": "jest --runInBand --forceExit",
"test:coverage": "jest --runInBand --forceExit --coverage",
"test:e2e": "playwright test"
},
"dependencies": {
@@ -19,19 +18,32 @@
"react-router-dom": "^6.24.0"
},
"devDependencies": {
"@babel/core": "^7.24.7",
"@babel/preset-env": "^7.24.7",
"@babel/preset-react": "^7.24.6",
"@playwright/test": "^1.44.1",
"@testing-library/jest-dom": "^6.4.6",
"@testing-library/react": "^16.0.0",
"@testing-library/react": "^14.3.1",
"@testing-library/user-event": "^14.5.2",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.2.1",
"@vitest/coverage-v8": "^1.6.0",
"autoprefixer": "^10.4.19",
"jsdom": "^24.1.0",
"babel-jest": "^29.7.0",
"identity-obj-proxy": "^3.0.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"postcss": "^8.4.39",
"tailwindcss": "^3.4.4",
"vite": "^4.5.3",
"vitest": "^1.6.0"
"vite": "^4.5.3"
},
"jest": {
"testEnvironment": "jsdom",
"setupFilesAfterEnv": ["<rootDir>/tests/setup.js"],
"testMatch": ["**/tests/**/*.test.{js,jsx}"],
"transform": {
"^.+\\.[jt]sx?$": "babel-jest"
},
"moduleNameMapper": {
"\\.(css|less|scss|sass)$": "identity-obj-proxy"
}
}
}
+14
View File
@@ -0,0 +1,14 @@
const { defineConfig, devices } = require('@playwright/test');
module.exports = defineConfig({
testDir: './e2e',
timeout: 30000,
retries: process.env.CI ? 2 : 0,
use: {
baseURL: process.env.E2E_BASE_URL || 'http://localhost:52867',
trace: 'on-first-retry',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
],
});
+73
View File
@@ -0,0 +1,73 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import AnimalForm from '../src/components/AnimalForm';
import * as client from '../src/api/client';
jest.mock('../src/api/client', () => ({
animalsApi: {
create: jest.fn(),
update: jest.fn(),
},
}));
const EXP_ID = 'exp-001';
const onSuccess = jest.fn();
const onCancel = jest.fn();
beforeEach(() => jest.clearAllMocks());
describe('AnimalForm', () => {
it('renders Animal ID and Animal Name fields', () => {
render(<AnimalForm experimentId={EXP_ID} onSuccess={onSuccess} onCancel={onCancel} />);
expect(screen.getByLabelText(/animal id/i)).toBeInTheDocument();
expect(screen.getByLabelText(/animal name/i)).toBeInTheDocument();
});
it('shows required field errors on empty submit', async () => {
render(<AnimalForm experimentId={EXP_ID} onSuccess={onSuccess} onCancel={onCancel} />);
fireEvent.click(screen.getByRole('button', { name: /add animal/i }));
expect(await screen.findAllByRole('alert')).toHaveLength(2);
expect(client.animalsApi.create).not.toHaveBeenCalled();
});
it('calls create with correct payload', async () => {
const animal = { id: 'a-1', animal_id_string: 'M-001', animal_name: 'Whiskers', experiment_id: EXP_ID };
client.animalsApi.create.mockResolvedValue(animal);
render(<AnimalForm experimentId={EXP_ID} onSuccess={onSuccess} onCancel={onCancel} />);
await userEvent.type(screen.getByLabelText(/animal id/i), 'M-001');
await userEvent.type(screen.getByLabelText(/animal name/i), 'Whiskers');
fireEvent.click(screen.getByRole('button', { name: /add animal/i }));
await waitFor(() =>
expect(client.animalsApi.create).toHaveBeenCalledWith({
animal_id_string: 'M-001',
animal_name: 'Whiskers',
experiment_id: EXP_ID,
})
);
await waitFor(() => expect(onSuccess).toHaveBeenCalledWith(animal));
});
it('pre-fills fields for existing animal and uses update API', async () => {
const existing = { id: 'a-1', animal_id_string: 'M-001', animal_name: 'Whiskers' };
const updated = { ...existing, animal_name: 'Mr Whiskers' };
client.animalsApi.update.mockResolvedValue(updated);
render(<AnimalForm existing={existing} experimentId={EXP_ID} onSuccess={onSuccess} onCancel={onCancel} />);
expect(screen.getByDisplayValue('Whiskers')).toBeInTheDocument();
const nameInput = screen.getByLabelText(/animal name/i);
await userEvent.clear(nameInput);
await userEvent.type(nameInput, 'Mr Whiskers');
fireEvent.click(screen.getByRole('button', { name: /save changes/i }));
await waitFor(() =>
expect(client.animalsApi.update).toHaveBeenCalledWith('a-1', {
animal_id_string: 'M-001',
animal_name: 'Mr Whiskers',
})
);
});
});
+42
View File
@@ -0,0 +1,42 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import Button from '../src/components/ui/Button';
describe('Button component', () => {
it('renders with children text', () => {
render(<Button>Click me</Button>);
expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument();
});
it('calls onClick when clicked', () => {
const onClick = jest.fn();
render(<Button onClick={onClick}>Click</Button>);
fireEvent.click(screen.getByRole('button'));
expect(onClick).toHaveBeenCalledTimes(1);
});
it('is disabled when disabled prop is true', () => {
render(<Button disabled>Submit</Button>);
expect(screen.getByRole('button')).toBeDisabled();
});
it('is disabled and shows spinner when loading', () => {
render(<Button loading>Submit</Button>);
const btn = screen.getByRole('button');
expect(btn).toBeDisabled();
expect(btn.querySelector('svg')).toBeTruthy();
});
it('renders danger variant', () => {
render(<Button variant="danger">Delete</Button>);
const btn = screen.getByRole('button');
expect(btn.className).toContain('bg-red-600');
});
it('does not call onClick when disabled', () => {
const onClick = jest.fn();
render(<Button disabled onClick={onClick}>Click</Button>);
fireEvent.click(screen.getByRole('button'));
expect(onClick).not.toHaveBeenCalled();
});
});
+93
View File
@@ -0,0 +1,93 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import DailyStatusForm from '../src/components/DailyStatusForm';
import * as client from '../src/api/client';
jest.mock('../src/api/client', () => ({
dailyStatusesApi: {
create: jest.fn(),
update: jest.fn(),
},
}));
const ANIMAL_ID = 'animal-001';
const onSuccess = jest.fn();
const onCancel = jest.fn();
beforeEach(() => jest.clearAllMocks());
describe('DailyStatusForm', () => {
it('renders date field (required) and optional fields', () => {
render(<DailyStatusForm animalId={ANIMAL_ID} onSuccess={onSuccess} onCancel={onCancel} />);
expect(screen.getByLabelText(/date/i)).toBeInTheDocument();
expect(screen.getByLabelText(/vitals/i)).toBeInTheDocument();
expect(screen.getByLabelText(/treatment/i)).toBeInTheDocument();
expect(screen.getByLabelText(/notes/i)).toBeInTheDocument();
expect(screen.getByLabelText(/experiment description/i)).toBeInTheDocument();
});
it('pre-fills date with today by default', () => {
render(<DailyStatusForm animalId={ANIMAL_ID} onSuccess={onSuccess} onCancel={onCancel} />);
const today = new Date().toISOString().slice(0, 10);
expect(screen.getByLabelText(/date/i)).toHaveValue(today);
});
it('shows error when date is cleared', async () => {
render(<DailyStatusForm animalId={ANIMAL_ID} onSuccess={onSuccess} onCancel={onCancel} />);
const dateInput = screen.getByLabelText(/date/i);
await userEvent.clear(dateInput);
fireEvent.click(screen.getByRole('button', { name: /log status/i }));
expect(await screen.findByRole('alert')).toHaveTextContent(/date is required/i);
expect(client.dailyStatusesApi.create).not.toHaveBeenCalled();
});
it('calls create with correct payload including animal_id', async () => {
const status = { id: 's-1', animal_id: ANIMAL_ID, date: '2024-06-01', vitals: 'HR 72' };
client.dailyStatusesApi.create.mockResolvedValue(status);
render(<DailyStatusForm animalId={ANIMAL_ID} onSuccess={onSuccess} onCancel={onCancel} />);
const dateInput = screen.getByLabelText(/date/i);
await userEvent.clear(dateInput);
await userEvent.type(dateInput, '2024-06-01');
await userEvent.type(screen.getByLabelText(/vitals/i), 'HR 72');
fireEvent.click(screen.getByRole('button', { name: /log status/i }));
await waitFor(() =>
expect(client.dailyStatusesApi.create).toHaveBeenCalledWith(
expect.objectContaining({ animal_id: ANIMAL_ID, vitals: 'HR 72' })
)
);
await waitFor(() => expect(onSuccess).toHaveBeenCalled());
});
it('shows "Save Changes" and pre-fills fields for existing status', () => {
const existing = {
id: 's-1',
date: '2024-06-01T00:00:00.000Z',
vitals: 'HR 72',
treatment: 'Control',
notes: 'Normal',
experiment_description: 'Baseline',
};
render(
<DailyStatusForm
existing={existing}
animalId={ANIMAL_ID}
onSuccess={onSuccess}
onCancel={onCancel}
/>
);
expect(screen.getByRole('button', { name: /save changes/i })).toBeInTheDocument();
expect(screen.getByDisplayValue('HR 72')).toBeInTheDocument();
});
it('calls onCancel when Cancel is clicked', () => {
render(<DailyStatusForm animalId={ANIMAL_ID} onSuccess={onSuccess} onCancel={onCancel} />);
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
expect(onCancel).toHaveBeenCalled();
});
});
+105
View File
@@ -0,0 +1,105 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ExperimentForm from '../src/components/ExperimentForm';
import * as client from '../src/api/client';
jest.mock('../src/api/client', () => ({
experimentsApi: {
create: jest.fn(),
update: jest.fn(),
},
}));
const onSuccess = jest.fn();
const onCancel = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
describe('ExperimentForm', () => {
it('renders title input and submit button', () => {
render(<ExperimentForm onSuccess={onSuccess} onCancel={onCancel} />);
expect(screen.getByLabelText(/experiment title/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /create experiment/i })).toBeInTheDocument();
});
it('shows validation error when submitting empty title', async () => {
render(<ExperimentForm onSuccess={onSuccess} onCancel={onCancel} />);
fireEvent.click(screen.getByRole('button', { name: /create experiment/i }));
expect(await screen.findByRole('alert')).toHaveTextContent(/title is required/i);
expect(client.experimentsApi.create).not.toHaveBeenCalled();
});
it('shows error when title exceeds 255 chars', async () => {
render(<ExperimentForm onSuccess={onSuccess} onCancel={onCancel} />);
await userEvent.type(screen.getByLabelText(/experiment title/i), 'x'.repeat(256));
fireEvent.click(screen.getByRole('button', { name: /create experiment/i }));
expect(await screen.findByRole('alert')).toHaveTextContent(/255/i);
expect(client.experimentsApi.create).not.toHaveBeenCalled();
});
it('calls create API with trimmed title and calls onSuccess', async () => {
const experiment = { id: 'abc-123', title: 'New Study' };
client.experimentsApi.create.mockResolvedValue(experiment);
render(<ExperimentForm onSuccess={onSuccess} onCancel={onCancel} />);
await userEvent.type(screen.getByLabelText(/experiment title/i), 'New Study');
fireEvent.click(screen.getByRole('button', { name: /create experiment/i }));
await waitFor(() => expect(client.experimentsApi.create).toHaveBeenCalledWith({ title: 'New Study' }));
await waitFor(() => expect(onSuccess).toHaveBeenCalledWith(experiment));
});
it('shows "Save Changes" button for existing experiment', () => {
render(
<ExperimentForm
existing={{ id: 'abc', title: 'Old Title' }}
onSuccess={onSuccess}
onCancel={onCancel}
/>
);
expect(screen.getByRole('button', { name: /save changes/i })).toBeInTheDocument();
expect(screen.getByDisplayValue('Old Title')).toBeInTheDocument();
});
it('calls update API for existing experiment', async () => {
const updated = { id: 'abc', title: 'Updated Title' };
client.experimentsApi.update.mockResolvedValue(updated);
render(
<ExperimentForm
existing={{ id: 'abc', title: 'Old Title' }}
onSuccess={onSuccess}
onCancel={onCancel}
/>
);
const input = screen.getByDisplayValue('Old Title');
await userEvent.clear(input);
await userEvent.type(input, 'Updated Title');
fireEvent.click(screen.getByRole('button', { name: /save changes/i }));
await waitFor(() =>
expect(client.experimentsApi.update).toHaveBeenCalledWith('abc', { title: 'Updated Title' })
);
await waitFor(() => expect(onSuccess).toHaveBeenCalledWith(updated));
});
it('calls onCancel when Cancel is clicked', () => {
render(<ExperimentForm onSuccess={onSuccess} onCancel={onCancel} />);
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
expect(onCancel).toHaveBeenCalled();
});
it('shows API error when create fails', async () => {
client.experimentsApi.create.mockRejectedValue({ message: 'Server error', details: null });
render(<ExperimentForm onSuccess={onSuccess} onCancel={onCancel} />);
await userEvent.type(screen.getByLabelText(/experiment title/i), 'Test');
fireEvent.click(screen.getByRole('button', { name: /create experiment/i }));
expect(await screen.findByRole('alert')).toHaveTextContent(/server error/i);
});
});
+31
View File
@@ -0,0 +1,31 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import Modal from '../src/components/ui/Modal';
describe('Modal component', () => {
it('does not render when isOpen is false', () => {
render(<Modal isOpen={false} onClose={() => {}} title="Test"><p>content</p></Modal>);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('renders title and children when open', () => {
render(<Modal isOpen={true} onClose={() => {}} title="My Modal"><p>Hello</p></Modal>);
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByText('My Modal')).toBeInTheDocument();
expect(screen.getByText('Hello')).toBeInTheDocument();
});
it('calls onClose when close button is clicked', () => {
const onClose = jest.fn();
render(<Modal isOpen={true} onClose={onClose} title="T"><p>x</p></Modal>);
fireEvent.click(screen.getByLabelText(/close modal/i));
expect(onClose).toHaveBeenCalled();
});
it('calls onClose when Escape key is pressed', () => {
const onClose = jest.fn();
render(<Modal isOpen={true} onClose={onClose} title="T"><p>x</p></Modal>);
fireEvent.keyDown(document, { key: 'Escape' });
expect(onClose).toHaveBeenCalled();
});
});
+1
View File
@@ -0,0 +1 @@
import '@testing-library/jest-dom';