Skip to content
This repository has been archived by the owner on Sep 10, 2024. It is now read-only.

Commit

Permalink
frontend: use in-memory history in test environments
Browse files Browse the repository at this point in the history
This removes the flakiness of location-based tests
  • Loading branch information
sandhose committed Oct 23, 2023
1 parent 31c81d0 commit b82db7a
Show file tree
Hide file tree
Showing 10 changed files with 73 additions and 80 deletions.
9 changes: 9 additions & 0 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"classnames": "^2.3.2",
"date-fns": "^2.30.0",
"graphql": "^16.8.1",
"history": "^5.3.0",
"i18next": "^23.6.0",
"i18next-browser-languagedetector": "^7.1.0",
"i18next-http-backend": "^2.2.2",
Expand Down
39 changes: 3 additions & 36 deletions frontend/src/components/Layout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,57 +15,24 @@
// @vitest-environment happy-dom

import { render } from "@testing-library/react";
import { Provider } from "jotai";
import { useHydrateAtoms } from "jotai/utils";
import { Suspense } from "react";
import { describe, expect, it, vi, afterAll, beforeEach } from "vitest";

import { currentUserIdAtom, GqlResult } from "../atoms";
import { appConfigAtom, locationAtom } from "../routing";
import { WithLocation } from "../test-utils/WithLocation";

import Layout from "./Layout";

beforeEach(async () => {
// For some reason, the locationAtom gets updated with `about:black` on render,
// so we need to set a "real" location and wait for the next tick
window.location.assign("https://example.com/");
// Wait the next tick for the location to update
await new Promise((resolve) => setTimeout(resolve, 0));
});

const HydrateLocation: React.FC<React.PropsWithChildren<{ path: string }>> = ({
children,
path,
}) => {
useHydrateAtoms([
[appConfigAtom, { root: "/", graphqlEndpoint: "/graphql" }],
[locationAtom, { pathname: path }],
]);
return <>{children}</>;
};

const WithLocation: React.FC<React.PropsWithChildren<{ path: string }>> = ({
children,
path,
}) => {
return (
<Provider>
<Suspense>
<HydrateLocation path={path}>{children}</HydrateLocation>
</Suspense>
</Provider>
);
};

describe("<Layout />", () => {
beforeEach(() => {
vi.spyOn(currentUserIdAtom, "read").mockResolvedValue(
"abc123" as unknown as GqlResult<string | null>,
);
});

afterAll(() => {
vi.restoreAllMocks();
});

it("renders app navigation correctly", async () => {
const component = render(
<WithLocation path="/account">
Expand Down
38 changes: 2 additions & 36 deletions frontend/src/components/NavItem/NavItem.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,47 +14,13 @@

// @vitest-environment happy-dom

import type { IWindow } from "happy-dom";
import { Provider } from "jotai";
import { useHydrateAtoms } from "jotai/utils";
import { create } from "react-test-renderer";
import { beforeEach, describe, expect, it } from "vitest";
import { describe, expect, it } from "vitest";

import { appConfigAtom, locationAtom } from "../../routing";
import { WithLocation } from "../../test-utils/WithLocation";

import NavItem from "./NavItem";

beforeEach(async () => {
const w = window as unknown as IWindow;

// For some reason, the locationAtom gets updated with `about:black` on render,
// so we need to set a "real" location and wait for the next tick
w.happyDOM.setURL("https://example.com/");
await w.happyDOM.whenAsyncComplete();
});

const HydrateLocation: React.FC<React.PropsWithChildren<{ path: string }>> = ({
children,
path,
}) => {
useHydrateAtoms([
[appConfigAtom, { root: "/", graphqlEndpoint: "/graphql" }],
[locationAtom, { pathname: path }],
]);
return <>{children}</>;
};

const WithLocation: React.FC<React.PropsWithChildren<{ path: string }>> = ({
children,
path,
}) => {
return (
<Provider>
<HydrateLocation path={path}>{children}</HydrateLocation>
</Provider>
);
};

describe("NavItem", () => {
it("render an active <NavItem />", () => {
const component = create(
Expand Down
42 changes: 41 additions & 1 deletion frontend/src/routing/atoms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,19 @@
// See the License for the specific language governing permissions and
// limitations under the License.

import { createBrowserHistory, createMemoryHistory } from "history";
import { atom } from "jotai";
import { atomWithLocation } from "jotai-location";

import appConfig, { AppConfig } from "../config";

import { Location, pathToRoute, Route, routeToPath } from "./routes";

/* Use memory history for testing */
export const history = import.meta.vitest
? createMemoryHistory()
: createBrowserHistory();

export const appConfigAtom = atom<AppConfig>(appConfig);

const locationToRoute = (root: string, location: Location): Route => {
Expand All @@ -30,7 +36,41 @@ const locationToRoute = (root: string, location: Location): Route => {
return pathToRoute(path);
};

export const locationAtom = atomWithLocation();
const getLocation = (): Location => {
return {
pathname: history.location.pathname,
searchParams: new URLSearchParams(history.location.search),
};
};

const applyLocation = (
location: Location,
options?: { replace?: boolean },
): void => {
const destination = {
pathname: location.pathname,
search: location.searchParams?.toString(),
};

if (options?.replace) {
history.replace(destination);
} else {
history.push(destination);
}
};

type Callback = () => void;
type Unsubscribe = () => void;
const subscribe = (callback: Callback): Unsubscribe =>
history.listen(() => {
callback();
});

export const locationAtom = atomWithLocation({
subscribe,
getLocation,
applyLocation,
});

export const routeAtom = atom(
(get) => {
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/routing/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,5 @@ export { default as Link } from "./Link";
export type { Route, Location } from "./routes";
export { pathToRoute, routeToPath } from "./routes";
export { getRouteActionRedirection } from "./actions";
export { routeAtom, locationAtom, appConfigAtom } from "./atoms";
export { routeAtom, locationAtom, appConfigAtom, history } from "./atoms";
export { useNavigationLink } from "./useNavigationLink";
2 changes: 1 addition & 1 deletion frontend/src/routing/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
// limitations under the License.

export type Location = Readonly<{
pathname?: string;
pathname: string;
searchParams?: URLSearchParams;
}>;

Expand Down
13 changes: 9 additions & 4 deletions frontend/src/test-utils/WithLocation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,20 @@
// See the License for the specific language governing permissions and
// limitations under the License.

// @vitest-environment happy-dom

import { Provider } from "jotai";
import { useHydrateAtoms } from "jotai/utils";
import { Suspense, useEffect } from "react";

import { appConfigAtom, locationAtom } from "../routing";
import { appConfigAtom, history, locationAtom } from "../routing";

const HydrateLocation: React.FC<React.PropsWithChildren<{ path: string }>> = ({
children,
path,
}) => {
useEffect(() => {
history.replace(path);
}, [path]);

useHydrateAtoms([
[appConfigAtom, { root: "/", graphqlEndpoint: "/graphql" }],
[locationAtom, { pathname: path }],
Expand All @@ -47,7 +50,9 @@ export const WithLocation: React.FC<
> = ({ children, path }) => {
return (
<Provider>
<HydrateLocation path={path || "/"}>{children}</HydrateLocation>
<Suspense>
<HydrateLocation path={path || "/"}>{children}</HydrateLocation>
</Suspense>
</Provider>
);
};
2 changes: 1 addition & 1 deletion frontend/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"DOM.Iterable",
"ESNext"
],
"types": ["vite/client"],
"types": ["vite/client", "vitest/importMeta"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": false,
Expand Down
5 changes: 5 additions & 0 deletions frontend/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ function i18nHotReload(): PluginOption {
},
};
}

export default defineConfig((env) => ({
base: "./",

Expand All @@ -45,6 +46,10 @@ export default defineConfig((env) => ({
},
},

define: {
"import.meta.vitest": "undefined",
},

build: {
manifest: true,
assetsDir: "",
Expand Down

0 comments on commit b82db7a

Please sign in to comment.