Skip to content

Commit

Permalink
Merge pull request #2 from rrecaredo/unit-tests
Browse files Browse the repository at this point in the history
Unit tests
  • Loading branch information
rrecaredo authored Jul 28, 2022
2 parents 99a053c + dd16da7 commit 67410c7
Show file tree
Hide file tree
Showing 11 changed files with 834 additions and 77 deletions.
4 changes: 2 additions & 2 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ module.exports = {
moduleDirectories: ["node_modules", "./src"],
moduleNameMapper: {
"\\.(css)$": "identity-obj-proxy",
"^@common$": "<rootDir>/lib/common/index.ts",
"^@components/(.*)$": "<rootDir>/lib/component/$1",
"^@common/(.*)$": "<rootDir>/lib/common/$1",
"^@components/(.*)$": "<rootDir>/lib/components/$1",
"^@hooks/(.*)$": "<rootDir>/lib/hooks/$1",
},
testMatch: ["<rootDir>/**/*.{spec,test}.{ts,tsx}"],
Expand Down
12 changes: 8 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,15 @@
"@floating-ui/react-dom": "^1.0.0",
"@floating-ui/react-dom-interactions": "^0.8.0",
"@tanstack/react-query": "^4.0.10",
"eslint-plugin-prettier": "^4.2.1",
"@testing-library/react-hooks": "^8.0.1",
"jest-fetch-mock": "^3.0.3",
"normalize.css": "^8.0.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-merge-refs": "^2.0.1",
"resize-observer-polyfill": "^1.5.1",
"styled-components": "^5.3.5",
"ulid": "^2.3.0",
"vite-tsconfig-paths": "^3.5.0"
"whatwg-fetch": "^3.6.2"
},
"devDependencies": {
"@babel/core": "^7.18.9",
Expand All @@ -45,13 +46,16 @@
"eslint-import-resolver-typescript": "^3.2.7",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-jsx-a11y": "^6.6.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react": "^7.30.1",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-storybook": "^0.6.1",
"jest": "^28.1.3",
"jest-environment-jsdom": "^28.1.3",
"msw": "^0.44.2",
"ts-jest": "^28.0.7",
"typescript": "^4.6.4",
"vite": "^3.0.0"
"vite": "^3.0.0",
"vite-tsconfig-paths": "^3.5.0"
}
}
Original file line number Diff line number Diff line change
@@ -1,34 +1,222 @@
import React from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ThemeProvider } from "styled-components";
import { rest } from "msw";
import { setupServer } from "msw/node";
import "whatwg-fetch";

import {
cleanup,
render,
screen,
fireEvent,
waitFor,
} from "@testing-library/react";

import userEvent from "@testing-library/user-event";

import { CancellableRequestButton } from "./CancellableRequestButton";
import { theme } from "@common/theme";
import { ButtonState } from '@components/smart-button';

global.ResizeObserver = require('resize-observer-polyfill')

const worker = setupServer(
rest.get("/api/rocket-launcher", (req, res, ctx) => {
return res(
ctx.json({
count: 500,
firstName: "Foo",
lastName: "Bar",
})
);
}),
rest.post("/api/timeout", (req, res, ctx) => {
return res(
ctx.delay(2000),
ctx.json({
count: 500,
firstName: "Foo",
lastName: "Bar",
})
);
})
);

beforeAll(() => worker.listen());
afterEach(() => worker.resetHandlers());
afterAll(() => worker.close());

export const STRINGS = {
LaunchRocketLabel: "Launch Rocket",
LaunchRocketTooltip: "Ignites the fuel",
IgnitionErrorTooltip: "Ignition error",
LaunchingLabel: "Launching",
CancelLaunchTooltip: "Cancel launch",
};

const queryClient = new QueryClient();

const wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<ThemeProvider theme={theme}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</ThemeProvider>
);

const labelProps = {
defaultLabel: STRINGS.LaunchRocketLabel,
workingLabel: STRINGS.LaunchingLabel,
errorLabel: STRINGS.LaunchRocketLabel,
defaultTooltip: STRINGS.LaunchRocketTooltip,
workingTooltip: STRINGS.CancelLaunchTooltip,
errorTooltip: STRINGS.IgnitionErrorTooltip,
};

const onSuccess = jest.fn();

const renderButton = (state: ButtonState, url: string) => {
render(
<CancellableRequestButton
url={url}
state={state}
onSuccess={onSuccess}
{...labelProps}
/>,
{
wrapper,
}
);
};

const fastApiUrl = "/api/rocket-launcher";
const slowApiUrl = "/api/timeout";

describe("Components > CancellableRequestButton", () => {
test("It should make a network request to a URL passed as props", () => {
throw new Error("Not implemented");
afterAll(cleanup);

beforeEach(jest.resetAllMocks);

test("It should make a network request to a URL passed as props", async () => {
renderButton("ready", fastApiUrl)

fireEvent.click(screen.getByRole("button"));

await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1));
});
test("It should show the 'Working' state for the duration of the network request", () => {
throw new Error("Not implemented");

test("It should show the 'Working' state for the duration of the network request", async () => {
renderButton("ready", slowApiUrl)

fireEvent.click(screen.getByRole("button"));

await waitFor(() =>
expect(screen.queryByText(/Launching/)).toBeInTheDocument()
);
});
test("It should optionally timeout the network request after a max duration passed as props", () => {
throw new Error("Not implemented");

test("It should optionally timeout the network request after a max duration passed as props", async () => {
jest.useFakeTimers();
const onSuccess = jest.fn();

renderButton("ready", slowApiUrl)

fireEvent.click(screen.getByRole("button"));

jest.advanceTimersByTime(3000);

expect(onSuccess).not.toHaveBeenCalled();

jest.useRealTimers();

// @TODO: There is an async operation happening after the component unmounts, likely coming from React Query
// which is causing a warning and potentially a memory leak that needs further investigation.
});
test("It should show the error state after the max duration is exceeded and the network request is cancelled", () => {
throw new Error("Not implemented");

test("It should show the error state after the max duration is exceeded and the network request is cancelled", async () => {
jest.useFakeTimers();

renderButton("ready", slowApiUrl)

fireEvent.click(screen.getByRole("button"));

jest.advanceTimersByTime(3000);

await waitFor(() =>
expect(screen.queryByText(/Ignition error/)).toBeInTheDocument()
);

jest.useRealTimers();
});
test("It should return to the default state after the network request completes if there is no timeout provided", () => {
throw new Error("Not implemented");

test("It should return to the default state after the network request completes", async () => {
renderButton("ready", fastApiUrl)

fireEvent.click(screen.getByRole("button"));

// This is a bit of a hack. An enhancement would be to have a data-state attribute on the button
// and verify that it is set back to 'ready' after the network request completes.
userEvent.hover(screen.getByRole("button"));

await waitFor(() =>
expect(screen.queryByText(/Ignites the fuel/)).toBeInTheDocument()
);
});
test("A second click of the button should abort a request that is in-flight and show the error state", () => {
throw new Error("Not implemented");
test("A second click of the button should abort a request that is in-flight and show the error state", async () => {
jest.useFakeTimers();

renderButton("ready", slowApiUrl)

const button = screen.getByRole("button");

fireEvent.click(button);

jest.advanceTimersByTime(500);

// Second click while the response has not yet been received
fireEvent.click(button);

await waitFor(() =>
expect(screen.queryByText(/Ignition error/)).toBeInTheDocument()
);

jest.useRealTimers();
});
test("It should be possible to put the button into each state via props ", () => {
throw new Error("Not implemented");

describe("It should be possible to put the button into each state via props", () => {
test("ready", async () => {
renderButton("ready", slowApiUrl)

expect(screen.queryByText(/Launch Rocket/)).toBeInTheDocument()
expect(screen.queryByText(/Ignites the fuel/)).toBeNull();
});

test("working", async () => {
renderButton("working", slowApiUrl);

expect(screen.queryByText(/Launching/)).toBeInTheDocument()
});

test("errored", async () => {
renderButton("error", slowApiUrl);

expect(screen.queryByText(/Ignition error/)).toBeInTheDocument()
});

test("disabled", async () => {
renderButton("disabled", slowApiUrl);
expect(screen.getByRole("button")).toBeDisabled();

});
});

test("The tooltip should not show if the button is disabled ", () => {
throw new Error("Not implemented");
});
test("The error state should not show if the button is disabled or working ", () => {
throw new Error("Not implemented");
renderButton("disabled", slowApiUrl);
userEvent.hover(screen.getByRole("button"));
expect(screen.queryByText(/Ignites the fuel/)).toBeNull();
});

test("The tooltip should always show when in the error state ", () => {
throw new Error("Not implemented");
renderButton("error", slowApiUrl);
expect(screen.queryByText(/Ignition error/)).toBeInTheDocument();
});
});
8 changes: 4 additions & 4 deletions src/lib/components/primitives/button/Button.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { ReactElement } from "react";
import { ComponentPropsWithoutRef, forwardRef, ReactElement, Ref } from "react";
import styled, { ThemedStyledProps } from "styled-components";
import { theme, StyleVariant } from "@common";

Expand Down Expand Up @@ -60,16 +60,16 @@ export const StyledButton = styled.button<ButtonProps>`
}
`;

export const Button = React.forwardRef(
export const Button = forwardRef(
(
{
variant,
disabled,
decorator,
children,
...rest
}: React.ComponentPropsWithoutRef<"button"> & ButtonProps,
ref: React.Ref<HTMLButtonElement>
}: ComponentPropsWithoutRef<"button"> & ButtonProps,
ref: Ref<HTMLButtonElement>
) => {
return (
<StyledButton
Expand Down
8 changes: 4 additions & 4 deletions src/lib/components/primitives/tooltip/Tooltip.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { ReactElement, useState, useMemo, useRef } from "react";
import { mergeRefs } from "react-merge-refs";
import { ReactElement, useState, useMemo, useRef, Children, cloneElement } from "react";
import { mergeRefs } from "../../../utils/MergeRefs";

import {
useFloating,
Expand Down Expand Up @@ -65,8 +65,8 @@ export function Tooltip({
useRole(context, { role: "tooltip" }),
]);

const contextElement = React.Children.only(
React.cloneElement(children, getReferenceProps({ ref, ...children.props }))
const contextElement = Children.only(
cloneElement(children, getReferenceProps({ ref, ...children.props }))
);

const arrowPositioningStyles = useMemo(() => {
Expand Down
6 changes: 5 additions & 1 deletion src/lib/components/smart-button/SmartButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,11 @@ export const SmartButton = ({
onClick={(e) => onClick && onClick(e)}
disabled={state === "disabled"}
variant={variant as StyleVariant}
decorator={state === "working" ? <Spinner /> : undefined}
decorator={
state === "working" ? (
<Spinner aria-valuetext="Loading" aria-busy="true" />
) : undefined
}
>
{label}
</Button>
Expand Down
19 changes: 0 additions & 19 deletions src/lib/hooks/useCancellableFetch.spec.ts

This file was deleted.

Loading

1 comment on commit 67410c7

@vercel
Copy link

@vercel vercel bot commented on 67410c7 Jul 28, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

rocket-launcher – ./

rocket-launcher.vercel.app
rocket-launcher-rrecaredo.vercel.app
rocket-launcher-git-main-rrecaredo.vercel.app

Please sign in to comment.