feat(tests): Jest backend (38 tests), Jest RTL frontend (28 tests), Playwright E2E spec
This commit is contained in:
@@ -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',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom';
|
||||
Reference in New Issue
Block a user