Skip to content

Commit 785f079

Browse files
authored
Add more Jest unit tests (#536)
Add more Jest unit tests to increase reliability of future changes not causing any codebase regression.
1 parent 984f042 commit 785f079

File tree

16 files changed

+596
-101
lines changed

16 files changed

+596
-101
lines changed

src/app/loading.test.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import '@testing-library/jest-dom';
2+
import { render, screen } from '@testing-library/react';
3+
import Loading from './loading';
4+
5+
describe('Loading', () => {
6+
it('renders a loading spinner with accessibility label', () => {
7+
render(<Loading />);
8+
expect(screen.getByLabelText(/loading/i)).toBeInTheDocument();
9+
});
10+
});

src/app/not-found.test.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import '@testing-library/jest-dom';
2+
import { render, screen } from '@testing-library/react';
3+
import { usePathname } from 'next/navigation';
4+
import NotFound from './not-found';
5+
6+
// Helper to mock usePathname
7+
jest.mock('next/navigation', () => ({
8+
...jest.requireActual('next/navigation'),
9+
usePathname: jest.fn(),
10+
}));
11+
12+
const realLocation = window.location;
13+
14+
describe('NotFound', () => {
15+
afterEach(() => {
16+
// @ts-expect-error: Overriding window.location for test cleanup
17+
globalThis.window.location = realLocation;
18+
jest.clearAllMocks();
19+
});
20+
21+
it('renders 404 page and navigation', () => {
22+
(usePathname as jest.Mock).mockReturnValue('/some-path');
23+
render(<NotFound />);
24+
25+
expect(screen.getByRole('heading', { name: /page not found/i })).toBeInTheDocument();
26+
expect(screen.getByText('404')).toBeInTheDocument();
27+
expect(screen.getByRole('link', { name: /go back home/i })).toBeInTheDocument();
28+
expect(screen.getByText('/some-path')).toBeInTheDocument();
29+
});
30+
});

src/components/Stars/StarsBackground.test.tsx

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,56 @@ describe('StarsBackground', () => {
1616
configurable: true,
1717
value: 1024,
1818
});
19-
});
2019

21-
it('should render stars background with proper accessibility attributes', async () => {
2220
render(<StarsBackground />);
21+
});
22+
23+
it('logs analytics on star hover', async () => {
24+
const mockLogAnalyticsEvent = require('@configs/firebase').logAnalyticsEvent;
25+
const stars = await screen.findAllByTestId('star');
26+
27+
fireEvent.mouseEnter(stars[0]);
28+
fireEvent.mouseLeave(stars[0]);
29+
30+
expect(mockLogAnalyticsEvent).toHaveBeenCalled();
31+
});
32+
33+
it('is accessible via keyboard (tab focus on star)', async () => {
34+
const stars = await screen.findAllByTestId('star');
35+
const star = stars[0];
36+
37+
star.tabIndex = 0; // Make focusable for test
38+
star.focus();
39+
40+
expect(star).toHaveFocus();
41+
});
42+
43+
it('renders gracefully with minimal stars (edge case)', async () => {
44+
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 20 });
45+
46+
const background = await screen.findByRole('img', { name: /starry background/i });
2347

48+
expect(background).toBeInTheDocument();
49+
50+
const stars = screen.queryAllByTestId('star');
51+
52+
expect(stars.length).toBeGreaterThanOrEqual(0);
53+
});
54+
55+
it('renders efficiently with a large number of stars (performance)', async () => {
56+
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 2000 });
57+
58+
const background = await screen.findByRole('img', { name: /starry background/i });
59+
60+
expect(background).toBeInTheDocument();
61+
62+
const stars = screen.getAllByTestId('star');
63+
64+
// Lower bound, actual count is random
65+
expect(stars.length).toBeGreaterThanOrEqual(10);
66+
});
67+
68+
it('should render stars background with proper accessibility attributes', async () => {
2469
await waitFor(() => {
2570
const background = screen.getByRole('img', { name: /starry background/i });
2671
expect(background).toBeInTheDocument();
@@ -29,8 +74,6 @@ describe('StarsBackground', () => {
2974
});
3075

3176
it('should create stars on mount', async () => {
32-
render(<StarsBackground />);
33-
3477
await waitFor(() => {
3578
const stars = screen.getAllByTestId('star');
3679
expect(stars.length).toBeGreaterThan(0);

src/components/banner/Avatar.test.tsx

Lines changed: 17 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,6 @@ jest.mock('@configs/firebase', () => ({
88
logAnalyticsEvent: jest.fn(),
99
}));
1010

11-
// Mock the aaaahhhh helper
12-
jest.mock('@helpers/aaaahhhh', () => ({
13-
aaaahhhh: jest.fn(),
14-
}));
15-
16-
// Mock lodash debounce
17-
jest.mock('lodash', () => ({
18-
debounce: jest.fn((fn) => fn),
19-
}));
20-
2111
// Mock Next.js Image component to capture the original src prop
2212
jest.mock('next/image', () => {
2313
const MockImage = React.forwardRef<HTMLImageElement, any>(
@@ -63,81 +53,41 @@ describe('Avatar', () => {
6353
expect(avatar).toHaveAttribute('src', '/images/drawn/profile_pic_drawn.webp');
6454
});
6555

66-
it('should start sneeze animation on every 5th hover', async () => {
67-
const { logAnalyticsEvent } = jest.requireMock('@configs/firebase');
56+
it('should handle click events', () => {
6857
render(<Avatar />);
6958

7059
const avatar = screen.getByTestId('profile_pic');
7160

72-
// Hover 4 times - no sneeze
73-
for (let i = 0; i < 4; i++) {
74-
fireEvent.mouseEnter(avatar);
75-
}
76-
77-
// 5th hover should trigger sneeze
78-
fireEvent.mouseEnter(avatar);
79-
80-
act(() => {
81-
jest.advanceTimersByTime(500);
82-
});
83-
84-
act(() => {
85-
jest.advanceTimersByTime(300);
86-
});
87-
88-
act(() => {
89-
jest.advanceTimersByTime(1000);
90-
});
91-
92-
expect(logAnalyticsEvent).toHaveBeenCalledWith('trigger_sneeze', {
93-
name: 'trigger_sneeze',
94-
type: 'hover',
95-
});
61+
expect(() => fireEvent.click(avatar)).not.toThrow();
9662
});
9763

98-
it('should trigger aaaahhhh effect on 6th sneeze', async () => {
99-
const { logAnalyticsEvent } = jest.requireMock('@configs/firebase');
100-
const { aaaahhhh } = jest.requireMock('@helpers/aaaahhhh');
101-
64+
it('should have proper styling', () => {
10265
render(<Avatar />);
10366

10467
const avatar = screen.getByTestId('profile_pic');
10568

106-
// Trigger sneezes 6 times (5 hovers each = 30 hovers total)
107-
for (let sneeze = 0; sneeze < 6; sneeze++) {
108-
for (let hover = 0; hover < 5; hover++) {
109-
fireEvent.mouseEnter(avatar);
110-
}
111-
112-
if (sneeze < 5) {
113-
act(() => {
114-
jest.advanceTimersByTime(2000); // Allow sneeze animation to complete
115-
});
116-
}
117-
}
118-
119-
expect(logAnalyticsEvent).toHaveBeenCalledWith('trigger_aaaahhhh', {
120-
name: 'trigger_aaaahhhh',
121-
type: 'hover',
69+
expect(avatar).toHaveStyle({
70+
borderRadius: '50%',
12271
});
123-
expect(aaaahhhh).toHaveBeenCalled();
12472
});
12573

126-
it('should handle click events', () => {
74+
it('should be accessible by keyboard (tab/focus/enter)', () => {
12775
render(<Avatar />);
128-
12976
const avatar = screen.getByTestId('profile_pic');
130-
131-
expect(() => fireEvent.click(avatar)).not.toThrow();
77+
avatar.tabIndex = 0;
78+
avatar.focus();
79+
expect(document.activeElement).toBe(avatar);
80+
fireEvent.keyDown(avatar, { key: 'Enter', code: 'Enter' });
81+
// Should not throw and should remain accessible
82+
expect(avatar).toBeInTheDocument();
13283
});
13384

134-
it('should have proper styling', () => {
85+
it('should handle image error gracefully', () => {
13586
render(<Avatar />);
136-
13787
const avatar = screen.getByTestId('profile_pic');
138-
139-
expect(avatar).toHaveStyle({
140-
borderRadius: '50%',
141-
});
88+
// Simulate image error event
89+
fireEvent.error(avatar);
90+
// Should still be in the document
91+
expect(avatar).toBeInTheDocument();
14292
});
14393
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import '@testing-library/jest-dom';
2+
import { render, screen } from '@testing-library/react';
3+
import Banner from './Banner';
4+
5+
describe('Banner', () => {
6+
it('renders the name in three parts with correct text', () => {
7+
render(<Banner />);
8+
// The heading has aria-label 'Name', not the visible name as accessible name
9+
const heading = screen.getByRole('heading', { name: 'Name' });
10+
expect(heading).toBeInTheDocument();
11+
// Check for each part in the visible text
12+
expect(heading).toHaveTextContent('Alexander');
13+
expect(heading).toHaveTextContent('Joo-Hyun');
14+
expect(heading).toHaveTextContent('Sullivan');
15+
});
16+
17+
it('renders the subtitle', () => {
18+
render(<Banner />);
19+
expect(screen.getByText(/software developer & bioinformatician/i)).toBeInTheDocument();
20+
});
21+
22+
it('has accessible heading and aria-label', () => {
23+
render(<Banner />);
24+
const heading = screen.getByRole('heading', { name: 'Name' });
25+
expect(heading).toHaveAttribute('aria-label', 'Name');
26+
});
27+
28+
it('has proper layout structure', () => {
29+
render(<Banner />);
30+
const heading = screen.getByRole('heading', { name: 'Name' });
31+
expect(heading.parentElement).toBeInTheDocument();
32+
});
33+
});

src/components/banner/Banner.tsx

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,8 @@
11
import Avatar from '@components/banner/Avatar';
2-
import { Box, Typography, useMediaQuery, useTheme } from '@mui/material';
2+
import { Box, Typography } from '@mui/material';
33

44
/** The banner at the top of the page */
55
export default function Banner() {
6-
/** Material-UI theme */
7-
const theme = useTheme();
8-
/** Whether or not the screen is small */
9-
const smallScreen = useMediaQuery(theme.breakpoints.down('sm'));
10-
116
return (
127
<Box
138
component='div'

src/components/cookie-snackbar/CookieSnackbar.test.tsx

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import '@testing-library/jest-dom';
2-
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
2+
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
33
import CookieSnackbar from './CookieSnackbar';
44

55
// Mock document.cookie
@@ -51,4 +51,32 @@ describe('CookieSnackbar', () => {
5151
expect(closeButton).toHaveAttribute('aria-label', 'close');
5252
});
5353
});
54+
55+
it('should auto-set cookie after 1 second if not already set', async () => {
56+
jest.useFakeTimers();
57+
render(<CookieSnackbar />);
58+
expect(document.cookie).not.toContain('cookie-consent=true');
59+
await act(async () => {
60+
jest.advanceTimersByTime(1000);
61+
});
62+
expect(document.cookie).toContain('cookie-consent=true');
63+
jest.useRealTimers();
64+
});
65+
66+
it('should not double-set cookie on repeated mounts', async () => {
67+
jest.useFakeTimers();
68+
render(<CookieSnackbar />);
69+
await act(async () => {
70+
jest.advanceTimersByTime(1000);
71+
});
72+
expect(document.cookie.match(/cookie-consent=true/g)?.length || 0).toBe(1);
73+
// Unmount and re-mount
74+
document.cookie = '';
75+
render(<CookieSnackbar />);
76+
await act(async () => {
77+
jest.advanceTimersByTime(1000);
78+
});
79+
expect(document.cookie.match(/cookie-consent=true/g)?.length || 0).toBe(1);
80+
jest.useRealTimers();
81+
});
5482
});
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import '@testing-library/jest-dom';
2+
import { fireEvent, render, screen } from '@testing-library/react';
3+
import Footer from './Footer';
4+
5+
// Mock the firebase analytics
6+
jest.mock('@configs/firebase', () => ({
7+
logAnalyticsEvent: jest.fn(),
8+
}));
9+
10+
// Mock Next.js Link component, filtering out Next.js-specific props like 'prefetch'
11+
jest.mock('next/link', () => {
12+
return ({ children, href, onClick, prefetch, as, replace, scroll, shallow, passHref, locale, ...props }: any) => {
13+
// Only pass valid <a> props
14+
return (
15+
<a href={href} onClick={onClick} {...props}>
16+
{children}
17+
</a>
18+
);
19+
};
20+
});
21+
22+
describe('Footer', () => {
23+
const mockLogAnalyticsEvent = jest.requireMock('@configs/firebase').logAnalyticsEvent;
24+
25+
beforeEach(() => {
26+
jest.clearAllMocks();
27+
28+
render(<Footer />);
29+
});
30+
31+
it('logs analytics when email button is clicked', () => {
32+
fireEvent.click(screen.getByLabelText('Email me'));
33+
34+
expect(mockLogAnalyticsEvent).toHaveBeenCalledWith('footer-email', expect.any(Object));
35+
});
36+
37+
it('logs analytics when resume button is clicked', () => {
38+
fireEvent.click(screen.getByLabelText('Resume'));
39+
40+
expect(mockLogAnalyticsEvent).toHaveBeenCalledWith('footer-resume', expect.any(Object));
41+
});
42+
43+
it('logs analytics when GitHub button is clicked', () => {
44+
fireEvent.click(screen.getByLabelText('GitHub repository button'));
45+
46+
expect(mockLogAnalyticsEvent).toHaveBeenCalledWith('footer-open-source', expect.any(Object));
47+
});
48+
49+
it('renders all social links and logs analytics on click', () => {
50+
const socialButtons = screen.getAllByRole('button', { name: /link|github/i });
51+
socialButtons.forEach((btn) => {
52+
fireEvent.click(btn);
53+
});
54+
55+
// At least one social analytics event should be logged
56+
expect(mockLogAnalyticsEvent).toHaveBeenCalled();
57+
});
58+
59+
it('has accessible labels for all main actions', () => {
60+
expect(screen.getByLabelText('Email me')).toBeInTheDocument();
61+
expect(screen.getByLabelText('Resume')).toBeInTheDocument();
62+
expect(screen.getByLabelText('GitHub repository')).toBeInTheDocument();
63+
});
64+
});

0 commit comments

Comments
 (0)