diff --git a/frontend/src/tests/Sidebar.test.tsx b/frontend/src/tests/Sidebar.test.tsx
new file mode 100644
index 00000000..0e7602b8
--- /dev/null
+++ b/frontend/src/tests/Sidebar.test.tsx
@@ -0,0 +1,145 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { MemoryRouter } from "react-router-dom";
+import { describe, it, expect, vi } from "vitest";
+import Sidebar from "../components/sidebar/Sidebar";
+import { ROUTES } from "../AppRoutes";
+
+// mock i18next to return the key as the translation
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}));
+
+// replace the child components with simple stand-ins. They have their own
+// tests (or are not part of what we test here), and this keeps the Sidebar
+// test focused on the Sidebar itself
+vi.mock("../components/languageSwitcher/LanguageSwitcher", () => ({
+ default: () =>
,
+}));
+
+vi.mock("../components/ThemeToggle", () => ({
+ default: () => ,
+}));
+
+// render the Sidebar inside a router, as NavLink needs one.
+// "route" is the page the router starts on, which decides the active link
+function renderSidebar({
+ isOpen = false,
+ route = ROUTES.plantMatch,
+}: { isOpen?: boolean; route?: string } = {}) {
+ const user = userEvent.setup();
+ const onCloseSidebar = vi.fn(); // a spy that records how many times it is called
+
+ render(
+
+
+ ,
+ );
+
+ return { user, onCloseSidebar };
+}
+
+describe("Sidebar", () => {
+ // sidebar displays the title, links to all pages, language switcher, and theme toggle
+ it("displays the title", () => {
+ renderSidebar();
+
+ expect(screen.getByText("sidebar.title")).toBeInTheDocument();
+ });
+
+ it("displays navigation links to all pages", () => {
+ renderSidebar();
+
+ expect(
+ screen.getByRole("link", { name: "sidebar.plantMatch" }),
+ ).toHaveAttribute("href", ROUTES.plantMatch);
+ expect(
+ screen.getByRole("link", { name: "sidebar.explorePlants" }),
+ ).toHaveAttribute("href", ROUTES.explore);
+ expect(
+ screen.getByRole("link", { name: "sidebar.myMatches" }),
+ ).toHaveAttribute("href", ROUTES.myMatches);
+ expect(
+ screen.getByRole("link", { name: "sidebar.profile" }),
+ ).toHaveAttribute("href", ROUTES.profile);
+ });
+
+ it("displays the language switcher and theme toggle", () => {
+ renderSidebar();
+
+ expect(screen.getByTestId("language-switcher")).toBeInTheDocument();
+ expect(screen.getByTestId("theme-toggle")).toBeInTheDocument();
+ });
+
+ // on mobile the sidebar is closed after the user picks a page or clicks the close button. The onCloseSidebar prop is called in both cases.
+ it("closes the sidebar when a link is clicked", async () => {
+ const { user, onCloseSidebar } = renderSidebar();
+
+ await user.click(screen.getByRole("link", { name: "sidebar.profile" }));
+
+ expect(onCloseSidebar).toHaveBeenCalledTimes(1);
+ });
+
+ it("closes the sidebar when the close button is clicked", async () => {
+ const { user, onCloseSidebar } = renderSidebar();
+
+ await user.click(screen.getByRole("button", { name: "Close menu" }));
+
+ expect(onCloseSidebar).toHaveBeenCalledTimes(1);
+ });
+
+ // data-open controls whether the sidebar is shown or hidden on mobile,
+ // so it should be "true" when isOpen is true and "false" when it is false.
+ // The