Merge pull request #132 from mcataford/chore/components-cleanup
chore: set up component testing + add coverage for all components
This commit is contained in:
commit
922463e570
16 changed files with 1647 additions and 1425 deletions
|
@ -24,6 +24,14 @@ two](https://github.com/mcataford/rss-reader/discussions/10) if you do. :tada:
|
|||
Once set up, `yarn start` will run the application locally (including a local instance of the Netlify function that
|
||||
handles CORS proxying).
|
||||
|
||||
### Testing
|
||||
|
||||
Frontend component tests are written using [React Testing Library](https://testing-library.com/docs/react-testing-library/intro/).
|
||||
|
||||
Rendering components should be done via the `testHelpers/renderUtils` exports, which provides a `renderComponent` helper
|
||||
that wraps the component in all the contexts provided to the application. This also sets up
|
||||
`@testing-library/user-events`.
|
||||
|
||||
## Contributing
|
||||
|
||||
The project welcomes contributions as long as they fit within the general roadmap, which is still TBD. Any contribution
|
||||
|
|
13
package.json
13
package.json
|
@ -11,7 +11,9 @@
|
|||
"start": "netlify dev",
|
||||
"start:app": "vite ./src --config ./vite.config.js --port 8080",
|
||||
"build": "vite build ./src --config ./vite.config.js --emptyOutDir",
|
||||
"build:watch": "vite build watch ./src --config ./vite.config.js"
|
||||
"build:watch": "vite build watch ./src --config ./vite.config.js",
|
||||
"test:watch": "vitest --config ./vite.config.js",
|
||||
"test": "vitest run --config ./vite.config.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.11.3",
|
||||
|
@ -27,14 +29,19 @@
|
|||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.5.3",
|
||||
"@netlify/functions": "^2.6.0",
|
||||
"@testing-library/dom": "^9.3.4",
|
||||
"@testing-library/jest-dom": "^6.4.2",
|
||||
"@testing-library/react": "^14.2.1",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"@vitejs/plugin-basic-ssl": "^1.1.0",
|
||||
"@vitejs/plugin-legacy": "^5.3.0",
|
||||
"jest": "29.3.1",
|
||||
"jsdom": "^24.0.0",
|
||||
"netlify-cli": "^17.16.2",
|
||||
"terser": "^5.27.1",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.1.3"
|
||||
"vite": "^5.1.3",
|
||||
"vitest": "^1.3.0"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import FeedsPanel from "./FeedsPanel";
|
||||
import NavigationBar from "./NavigationBar";
|
||||
import SettingsPanel from "./SettingsPanel";
|
||||
import useNavigation, { routes } from "./hooks/useNavigation";
|
||||
import FeedsPanel from "@/components/FeedsPanel";
|
||||
import NavigationBar from "@/components/NavigationBar";
|
||||
import SettingsPanel from "@/components/SettingsPanel";
|
||||
import useNavigation, { routes } from "@/hooks/useNavigation";
|
||||
|
||||
export default function App() {
|
||||
const { location } = useNavigation();
|
||||
|
|
125
src/components/FeedsPanel.test.tsx
Normal file
125
src/components/FeedsPanel.test.tsx
Normal file
|
@ -0,0 +1,125 @@
|
|||
import { describe, it, vi, afterEach, expect } from "vitest";
|
||||
import { screen, within } from "@testing-library/react";
|
||||
|
||||
import { renderComponent as renderComponentInner } from "@/testHelpers/renderUtils";
|
||||
import * as FeedsHook from "@/hooks/useRSSFeeds";
|
||||
|
||||
import FeedsPanel from "@/components/FeedsPanel";
|
||||
|
||||
function renderComponent() {
|
||||
return renderComponentInner(FeedsPanel);
|
||||
}
|
||||
|
||||
describe("FeedsPanel", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
it("displays a no-item message if there are no items to display", async () => {
|
||||
vi.spyOn(FeedsHook, "default").mockReturnValue({
|
||||
feeds: [],
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
expect(screen.getByText(/nothing to see here/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("display a list of available stories", async () => {
|
||||
const mockFeeds = [
|
||||
{
|
||||
lastPull: "0",
|
||||
title: "Feed A",
|
||||
items: [
|
||||
{
|
||||
title: "Item A",
|
||||
url: "local.host/item-a",
|
||||
published: new Date("2012-12-12"),
|
||||
feedTitle: "Feed A",
|
||||
source: "",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
lastPull: "0",
|
||||
title: "Feed B",
|
||||
items: [
|
||||
{
|
||||
title: "Item B",
|
||||
url: "local.host/item-b",
|
||||
published: new Date("2012-12-12"),
|
||||
feedTitle: "Feed B",
|
||||
source: "",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
vi.spyOn(FeedsHook, "default").mockReturnValue({
|
||||
feeds: mockFeeds,
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
// FIXME: Weak selection.
|
||||
const feedItems = await screen.getByRole("list");
|
||||
|
||||
expect(feedItems.children.length).toEqual(2);
|
||||
});
|
||||
|
||||
describe("ItemCard", () => {
|
||||
const mockFeed = {
|
||||
lastPull: "0",
|
||||
title: "Feed A",
|
||||
items: [
|
||||
{
|
||||
title: "Item A",
|
||||
url: "local.host/item-a",
|
||||
published: new Date("2012-12-12"),
|
||||
feedTitle: "Feed A",
|
||||
source: "",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it("displays the feed title the item is associated with", async () => {
|
||||
vi.spyOn(FeedsHook, "default").mockReturnValue({
|
||||
feeds: [mockFeed],
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
// FIXME: Weak selection.
|
||||
const feedItem = await screen.getByRole("listitem");
|
||||
|
||||
expect(
|
||||
within(feedItem).getByText(new RegExp(mockFeed.title, "i")),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays the publication date associated with the item", async () => {
|
||||
vi.spyOn(FeedsHook, "default").mockReturnValue({
|
||||
feeds: [mockFeed],
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
// FIXME: Weak selection.
|
||||
const feedItem = await screen.getByRole("listitem");
|
||||
|
||||
expect(within(feedItem).getByText(/12\/12\/2012/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays a clickable link to the full content for the item", async () => {
|
||||
vi.spyOn(FeedsHook, "default").mockReturnValue({
|
||||
feeds: [mockFeed],
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
const feedItemLink = await screen.getByLabelText("Open item");
|
||||
|
||||
expect(feedItemLink).toBeInTheDocument();
|
||||
expect(feedItemLink.getAttribute("href")).toEqual(mockFeed.items[0].url);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -2,9 +2,9 @@ import Box from "@mui/material/Box";
|
|||
import Card from "@mui/material/Card";
|
||||
import Typography from "@mui/material/Typography";
|
||||
|
||||
import useRSSFeeds from "./hooks/useRSSFeeds";
|
||||
import useSettings from "./hooks/useSettings";
|
||||
import sortFeedItemsByDate from "./utils";
|
||||
import useRSSFeeds from "@/hooks/useRSSFeeds";
|
||||
import useSettings from "@/hooks/useSettings";
|
||||
import sortFeedItemsByDate from "@/utils";
|
||||
|
||||
interface CardProps {
|
||||
title: string;
|
||||
|
@ -27,8 +27,10 @@ function ItemCard(props: CardProps) {
|
|||
timeZone: "UTC",
|
||||
});
|
||||
return (
|
||||
<Card sx={root}>
|
||||
<a href={url}>{title}</a>
|
||||
<Card sx={root} role="listitem">
|
||||
<a href={url} aria-label="Open item">
|
||||
{title}
|
||||
</a>
|
||||
<span>{`${feedTitle} - ${formattedDate}`}</span>
|
||||
</Card>
|
||||
);
|
||||
|
@ -63,7 +65,7 @@ export default function FeedsPanel() {
|
|||
));
|
||||
|
||||
return (
|
||||
<Box display="flex" flexDirection="column">
|
||||
<Box display="flex" flexDirection="column" role="list">
|
||||
{cardList.length > 0 ? cardList : <NoItemsNotice />}
|
||||
</Box>
|
||||
);
|
74
src/components/NavigationBar.test.tsx
Normal file
74
src/components/NavigationBar.test.tsx
Normal file
|
@ -0,0 +1,74 @@
|
|||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { screen } from "@testing-library/react";
|
||||
|
||||
import { renderComponent as renderComponentInner } from "@/testHelpers/renderUtils";
|
||||
import * as NavigationHook from "@/hooks/useNavigation";
|
||||
|
||||
import NavigationBar from "./NavigationBar";
|
||||
|
||||
function renderComponent() {
|
||||
return renderComponentInner(NavigationBar);
|
||||
}
|
||||
|
||||
describe("NavigationBar", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
it("renders navigation buttons", async () => {
|
||||
renderComponent();
|
||||
|
||||
const buttons = await screen.findAllByRole("button");
|
||||
|
||||
expect(buttons.length).toEqual(2);
|
||||
expect(
|
||||
screen.getByText(/Your feeds/, { selector: "button" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Settings/, { selector: "button" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each`
|
||||
destination | buttonTextPattern | expectedUrl
|
||||
${"feeds"} | ${/Your feeds/} | ${"/feeds/"}
|
||||
${"settings"} | ${/Settings/} | ${"/settings/"}
|
||||
`(
|
||||
"clicking navigation buttons triggers navigation ($destination)",
|
||||
async ({ buttonTextPattern, expectedUrl }) => {
|
||||
const mockPushHistory = vi.spyOn(window.history, "pushState");
|
||||
|
||||
const { user } = renderComponent();
|
||||
|
||||
const feedsButton = await screen.getByText(buttonTextPattern, {
|
||||
selector: "button",
|
||||
});
|
||||
|
||||
await user.click(feedsButton);
|
||||
|
||||
// The history and location are updated.
|
||||
expect(mockPushHistory).toHaveBeenCalledTimes(1);
|
||||
expect(mockPushHistory).toHaveBeenCalledWith({}, "", expectedUrl);
|
||||
expect(window.location.pathname).toEqual(expectedUrl);
|
||||
},
|
||||
);
|
||||
|
||||
it.each`
|
||||
url | expectedText
|
||||
${"/settings/"} | ${"Settings"}
|
||||
${"/feeds/"} | ${"Your feeds"}
|
||||
`(
|
||||
"displays the name of the current location",
|
||||
async ({ url, expectedText }) => {
|
||||
const mockCurrentLocation = vi
|
||||
.spyOn(NavigationHook, "default")
|
||||
.mockReturnValue({ location: url, navigate: () => {} });
|
||||
|
||||
renderComponent();
|
||||
|
||||
const locationTitle = await screen.getByLabelText("current location");
|
||||
|
||||
expect(locationTitle).toBeInTheDocument();
|
||||
expect(locationTitle.textContent).toEqual(expectedText);
|
||||
},
|
||||
);
|
||||
});
|
|
@ -3,7 +3,7 @@ import Button from "@mui/material/Button";
|
|||
import Toolbar from "@mui/material/Toolbar";
|
||||
import Typography from "@mui/material/Typography";
|
||||
|
||||
import useNavigation, { routes } from "./hooks/useNavigation";
|
||||
import useNavigation, { routes } from "@/hooks/useNavigation";
|
||||
|
||||
const title = {
|
||||
flexGrow: 1,
|
||||
|
@ -21,7 +21,7 @@ export default function NavigationBar() {
|
|||
<>
|
||||
<AppBar position="fixed">
|
||||
<Toolbar>
|
||||
<Typography variant="h6" style={title}>
|
||||
<Typography variant="h6" style={title} aria-label="current location">
|
||||
{routePrettyNames[location]}
|
||||
</Typography>
|
||||
<Button
|
134
src/components/SettingsPanel.test.tsx
Normal file
134
src/components/SettingsPanel.test.tsx
Normal file
|
@ -0,0 +1,134 @@
|
|||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { screen, within } from "@testing-library/react";
|
||||
|
||||
import { renderComponent as renderComponentInner } from "@/testHelpers/renderUtils";
|
||||
import * as LocalStorageHook from "@/hooks/useLocalStorage";
|
||||
|
||||
import SettingsPanel from "./SettingsPanel";
|
||||
|
||||
function renderComponent() {
|
||||
return renderComponentInner(SettingsPanel);
|
||||
}
|
||||
|
||||
describe("SettingsPanel", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("renders a text field to capture user-defined urls", async () => {
|
||||
renderComponent();
|
||||
|
||||
const textField = await screen.getByLabelText(/feed urls/i, {
|
||||
selector: "textarea",
|
||||
});
|
||||
|
||||
expect(textField).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a submission button to save the settings state", async () => {
|
||||
renderComponent();
|
||||
|
||||
const submitButton = await screen.getByLabelText(/update feeds/i, {
|
||||
selector: "button",
|
||||
});
|
||||
|
||||
expect(submitButton).toBeInTheDocument();
|
||||
expect(submitButton.textContent).toEqual("Save");
|
||||
});
|
||||
|
||||
it("pre-populates text field with saved user-defined urls", async () => {
|
||||
const mockSettings = {
|
||||
feedUrls: ["test-feed-url.com", "other-test-feed-url.ca"],
|
||||
};
|
||||
|
||||
const mockLocalStorage = vi
|
||||
.spyOn(LocalStorageHook, "default")
|
||||
.mockReturnValue({
|
||||
setValue: () => {},
|
||||
// biome-ignore lint/suspicious/noExplicitAny: The function usually takes a generic.
|
||||
getValue: (key: string): any => {
|
||||
if (key === "settings") return mockSettings;
|
||||
|
||||
throw Error("Not implemented.");
|
||||
},
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
const textField = await screen.getByLabelText(/feed urls/i, {
|
||||
selector: "textarea",
|
||||
});
|
||||
|
||||
expect(textField.textContent).toEqual(mockSettings.feedUrls.join("\n"));
|
||||
});
|
||||
|
||||
it("displays parsed urls from user input", async () => {
|
||||
const mockSettings = {
|
||||
feedUrls: ["test-feed-url.com", "other-test-feed-url.ca"],
|
||||
};
|
||||
|
||||
const mockLocalStorage = vi
|
||||
.spyOn(LocalStorageHook, "default")
|
||||
.mockReturnValue({
|
||||
setValue: () => {},
|
||||
// biome-ignore lint/suspicious/noExplicitAny: The function usually takes a generic.
|
||||
getValue: (key: string): any => {
|
||||
if (key === "settings") return mockSettings;
|
||||
|
||||
throw Error("Not implemented.");
|
||||
},
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
// FIXME: Weak assertion / selection of component.
|
||||
const parsedUrlCards = await screen.getByRole("list");
|
||||
|
||||
expect(parsedUrlCards.children.length).toEqual(
|
||||
mockSettings.feedUrls.length,
|
||||
);
|
||||
expect(parsedUrlCards.children[0].textContent.trim()).toEqual(
|
||||
mockSettings.feedUrls[0],
|
||||
);
|
||||
expect(parsedUrlCards.children[1].textContent.trim()).toEqual(
|
||||
mockSettings.feedUrls[1],
|
||||
);
|
||||
});
|
||||
|
||||
it.each`
|
||||
scenario | url | expectValid
|
||||
${"valid"} | ${"https://www.my-feed.com"} | ${true}
|
||||
${"invalid"} | ${"not-a-url"} | ${false}
|
||||
`(
|
||||
"displays validation status of saved user-defined urls ($scenario)",
|
||||
async ({ url, expectValid }) => {
|
||||
const mockSettings = {
|
||||
feedUrls: [url],
|
||||
};
|
||||
|
||||
const mockLocalStorage = vi
|
||||
.spyOn(LocalStorageHook, "default")
|
||||
.mockReturnValue({
|
||||
setValue: () => {},
|
||||
// biome-ignore lint/suspicious/noExplicitAny: The function usually takes a generic.
|
||||
getValue: (key: string): any => {
|
||||
if (key === "settings") return mockSettings;
|
||||
|
||||
throw Error("Not implemented.");
|
||||
},
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
const validatedUrl = await screen.getByText(url, {
|
||||
selector: "[role=listitem]",
|
||||
});
|
||||
|
||||
expect(
|
||||
within(validatedUrl).getByTestId(
|
||||
expectValid ? /CheckCircleOutlineIcon/ : /ErrorOutlineIcon/,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
});
|
|
@ -10,7 +10,7 @@ import { useState } from "react";
|
|||
|
||||
import { css } from "@emotion/react";
|
||||
|
||||
import useSettings from "./hooks/useSettings";
|
||||
import useSettings from "@/hooks/useSettings";
|
||||
|
||||
const urlCard = {
|
||||
margin: "5px",
|
||||
|
@ -30,16 +30,25 @@ export default function SettingsPanel() {
|
|||
const settings = getSettings();
|
||||
const [feedUrlsForm, setFeedUrlsForm] = useState(settings.feedUrls);
|
||||
|
||||
const urlCards = feedUrlsForm.map((url) => (
|
||||
<Card key={`url_${url}`} variant="outlined" style={urlCard}>
|
||||
{isValidUrl(url) ? (
|
||||
<CheckCircleOutlineIcon color="primary" />
|
||||
) : (
|
||||
<ErrorOutlineIcon color="error" />
|
||||
)}{" "}
|
||||
{url}
|
||||
</Card>
|
||||
));
|
||||
const urlCards = feedUrlsForm.map((url) => {
|
||||
const isValid = isValidUrl(url);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={`url_${url}`}
|
||||
variant="outlined"
|
||||
style={urlCard}
|
||||
role="listitem"
|
||||
>
|
||||
{isValid ? (
|
||||
<CheckCircleOutlineIcon color="primary" />
|
||||
) : (
|
||||
<ErrorOutlineIcon color="error" />
|
||||
)}{" "}
|
||||
{url}
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<Box display="flex" flexDirection="column">
|
||||
|
@ -55,12 +64,13 @@ export default function SettingsPanel() {
|
|||
value={feedUrlsForm.join("\n")}
|
||||
onChange={(v) => setFeedUrlsForm(v.target.value.split("\n"))}
|
||||
/>
|
||||
<Box display="flex" flexDirection="column">
|
||||
<Box display="flex" flexDirection="column" role="list">
|
||||
{urlCards}
|
||||
</Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
aria-label="update feeds"
|
||||
onClick={() => {
|
||||
const validUrls = feedUrlsForm.filter(isValidUrl);
|
||||
setSettings<string[]>("feedUrls", validUrls);
|
12
src/testHelpers/components.tsx
Normal file
12
src/testHelpers/components.tsx
Normal file
|
@ -0,0 +1,12 @@
|
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { NavigationProvider } from "@/hooks/useNavigation";
|
||||
|
||||
export function TestComponentWrapper({ children }) {
|
||||
return (
|
||||
<NavigationProvider>
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
</NavigationProvider>
|
||||
);
|
||||
}
|
16
src/testHelpers/renderUtils.tsx
Normal file
16
src/testHelpers/renderUtils.tsx
Normal file
|
@ -0,0 +1,16 @@
|
|||
import { render } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import { TestComponentWrapper } from "./components";
|
||||
|
||||
export function renderComponent<ComponentProps>(
|
||||
Component,
|
||||
props?: ComponentProps,
|
||||
) {
|
||||
return {
|
||||
...render(<Component {...(props ?? {})} />, {
|
||||
wrapper: TestComponentWrapper,
|
||||
}),
|
||||
user: userEvent.setup(),
|
||||
};
|
||||
}
|
2
testSetup.ts
Normal file
2
testSetup.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
import { vi } from "vitest";
|
||||
import "@testing-library/jest-dom";
|
14
testSetup.tsx
Normal file
14
testSetup.tsx
Normal file
|
@ -0,0 +1,14 @@
|
|||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { NavigationProvider } from "@/hooks/useNavigation";
|
||||
|
||||
export function TestComponentWrappers({ children }) {
|
||||
return (
|
||||
<NavigationProvider>
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
</NavigationProvider>
|
||||
);
|
||||
}
|
|
@ -6,7 +6,13 @@
|
|||
"moduleResolution": "node",
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"rootDir": "."
|
||||
"rootDir": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"types": [
|
||||
"@testing-library/jest-dom"
|
||||
]
|
||||
},
|
||||
"include": ["src/**/*", "netlify/**/*"]
|
||||
}
|
||||
|
|
|
@ -18,7 +18,7 @@ export default defineConfig({
|
|||
},
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
//setupFiles: ["./src/tests/testSetup.ts"],
|
||||
setupFiles: ["./testSetup.ts"],
|
||||
include: ["./src/**/*.test.ts", "./src/**/*.test.tsx"],
|
||||
globals: true,
|
||||
},
|
||||
|
|
Loading…
Reference in a new issue