Merge pull request #132 from mcataford/chore/components-cleanup

chore: set up component testing + add coverage for all components
This commit is contained in:
Marc 2024-02-19 23:32:34 -05:00 committed by GitHub
commit 922463e570
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1647 additions and 1425 deletions

View file

@ -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

View file

@ -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"
}
}

View file

@ -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();

View 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);
});
});
});

View file

@ -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>
);

View 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);
},
);
});

View file

@ -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

View 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();
},
);
});

View file

@ -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);

View 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>
);
}

View 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
View file

@ -0,0 +1,2 @@
import { vi } from "vitest";
import "@testing-library/jest-dom";

14
testSetup.tsx Normal file
View 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>
);
}

View file

@ -6,7 +6,13 @@
"moduleResolution": "node",
"esModuleInterop": true,
"skipLibCheck": true,
"rootDir": "."
"rootDir": ".",
"paths": {
"@/*": ["./src/*"]
},
"types": [
"@testing-library/jest-dom"
]
},
"include": ["src/**/*", "netlify/**/*"]
}

View file

@ -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,
},

2604
yarn.lock

File diff suppressed because it is too large Load diff