fix(nova-react-test-utils): prevent component unmount when using decorator with RTL rerender (#116)

* don't unmount on RTL rerender

* Change files

---------

Co-authored-by: Stanislaw Wilczynski <Stanislaw.Wilczynski@microsoft.com>
This commit is contained in:
Stanisław 2024-09-23 19:13:36 +02:00 коммит произвёл GitHub
Родитель cccbf3c596
Коммит d8c4944fde
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
13 изменённых файлов: 245 добавлений и 142 удалений

Просмотреть файл

@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "add test for RTL rerender",
"packageName": "@nova/examples",
"email": "Stanislaw.Wilczynski@microsoft.com",
"dependentChangeType": "patch"
}

Просмотреть файл

@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "don't unmount on RTL rerender",
"packageName": "@nova/react-test-utils",
"email": "Stanislaw.Wilczynski@microsoft.com",
"dependentChangeType": "patch"
}

Просмотреть файл

@ -5,11 +5,48 @@ import * as React from "react";
import "@testing-library/jest-dom";
import { prepareStoryContextForTest } from "@nova/react-test-utils/apollo";
import { executePlayFunction } from "../../testing-utils/executePlayFunction";
import type { EventWrapper, NovaEventing } from "@nova/types";
const { ArtificialFailureToShowcaseDecoratorBehaviorInCaseOfADevCausedError } =
composeStories(stories);
const {
ArtificialFailureToShowcaseDecoratorBehaviorInCaseOfADevCausedError,
Primary,
} = composeStories(stories);
const bubbleMock = jest.fn<NovaEventing, [EventWrapper]>();
const generateEventMock = jest.fn<NovaEventing, [EventWrapper]>();
jest.mock("@nova/react", () => ({
...jest.requireActual("@nova/react"),
useNovaEventing: () => ({
bubble: bubbleMock,
generateEvent: generateEventMock,
}),
}));
beforeEach(() => {
jest.clearAllMocks();
});
describe("Feedback", () => {
it("rerenders without unmounting", async () => {
const { rerender, findByRole } = render(<Primary />);
await findByRole("button", { name: "Like" });
let telemetryEvents = generateEventMock.mock.calls.filter(
([{ event }]) => event.type === "feedbackTelemetry",
);
expect(telemetryEvents).toHaveLength(1); // coming from strict mode
rerender(<Primary />);
await findByRole("button", { name: "Like" });
telemetryEvents = generateEventMock.mock.calls.filter(
([{ event }]) => event.type === "feedbackTelemetry",
);
expect(telemetryEvents).toHaveLength(1); // there are no remounts of the component on Story rerender
});
it("throws an error when the developer makes a mistake", async () => {
const { container } = render(
<ArtificialFailureToShowcaseDecoratorBehaviorInCaseOfADevCausedError />,

Просмотреть файл

@ -1,13 +1,11 @@
import {
graphql,
useFragment,
useMutation,
useNovaEventing,
} from "@nova/react";
import { graphql, useFragment, useMutation } from "@nova/react";
import * as React from "react";
import type { FeedbackComponent_LikeMutation } from "./__generated__/FeedbackComponent_LikeMutation.graphql";
import type { Feedback_feedbackFragment$key } from "./__generated__/Feedback_feedbackFragment.graphql";
import { events } from "../../events/events";
import {
useOnDeleteFeedback,
useFeedbackTelemetry,
} from "../../events/helpers";
type Props = {
feedback: Feedback_feedbackFragment$key;
@ -45,6 +43,12 @@ export const FeedbackComponent = (props: Props) => {
const feedbackTelemetry = useFeedbackTelemetry();
React.useEffect(() => {
return () => {
feedbackTelemetry("FeedbackComponentUnmounted");
};
}, []);
return (
<div
style={{
@ -95,27 +99,3 @@ export const FeedbackComponent = (props: Props) => {
</div>
);
};
const useOnDeleteFeedback = (feedbackId: string, feedbackText: string) => {
const eventing = useNovaEventing();
return React.useCallback(
(reactEvent: React.SyntheticEvent) => {
const event = events.onDeleteFeedback({ feedbackId, feedbackText });
void eventing.bubble({ event, reactEvent });
},
[eventing, feedbackId, feedbackText],
);
};
const useFeedbackTelemetry = () => {
const eventing = useNovaEventing();
return React.useCallback(
(operation: "FeedbackLiked" | "FeedbackUnliked") => {
const event = events.feedbackTelemetry({ operation });
void eventing.generateEvent({ event });
},
[eventing],
);
};

Просмотреть файл

@ -6,7 +6,7 @@ import "@testing-library/jest-dom";
import { prepareStoryContextForTest } from "@nova/react-test-utils/apollo";
import { executePlayFunction } from "../../testing-utils/executePlayFunction";
import type { NovaEventing, EventWrapper } from "@nova/types";
import { eventTypes, originator } from "../../events/events";
import { eventTypes, type FeedbackTelemetryEvent } from "../../events/events";
const bubbleMock = jest.fn<NovaEventing, [EventWrapper]>();
const generateEventMock = jest.fn<NovaEventing, [EventWrapper]>();
@ -47,16 +47,22 @@ describe("FeedbackContainer", () => {
it("should show unlike button after clicking like button and send telemetry event", async () => {
const { container } = render(<Like />);
await executePlayFunction(Like, prepareStoryContextForTest(Like, container));
await executePlayFunction(
Like,
prepareStoryContextForTest(Like, container),
);
const button = await screen.findByRole("button", { name: "Unlike" });
expect(button).toBeInTheDocument();
await waitFor(() => expect(generateEventMock).toHaveBeenCalledTimes(1));
const telemetryEvents = generateEventMock.mock.calls
.filter(([{ event }]) => event.type === eventTypes.feedbackTelemetry)
.map(([{ event }]) => event as FeedbackTelemetryEvent);
const receivedEvent = generateEventMock.mock.calls[0][0].event;
expect(receivedEvent.type).toEqual(eventTypes.feedbackTelemetry);
expect(receivedEvent.originator).toEqual(originator);
expect(receivedEvent.data?.()).toEqual({ operation: "FeedbackLiked" });
const unlikeEvents = telemetryEvents.filter(
(event) => event.data?.().operation === "FeedbackLiked",
);
expect(unlikeEvents).toHaveLength(1);
});
it("should show an error if the like button fails", async () => {

Просмотреть файл

@ -6,7 +6,7 @@ type DeleteFeedbackRequest = {
};
type FeedbackTelemetryRequest = {
operation: "FeedbackLiked" | "FeedbackUnliked";
operation: "FeedbackLiked" | "FeedbackUnliked" | "FeedbackComponentUnmounted";
};
export const originator = "Feedback" as const;

Просмотреть файл

@ -0,0 +1,35 @@
import { useNovaEventing } from "@nova/react";
import { useCallback } from "react";
import { events } from "./events";
export const useOnDeleteFeedback = (
feedbackId: string,
feedbackText: string,
) => {
const eventing = useNovaEventing();
return useCallback(
(reactEvent: React.SyntheticEvent) => {
const event = events.onDeleteFeedback({ feedbackId, feedbackText });
void eventing.bubble({ event, reactEvent });
},
[eventing, feedbackId, feedbackText],
);
};
export const useFeedbackTelemetry = () => {
const eventing = useNovaEventing();
return useCallback(
(
operation:
| "FeedbackLiked"
| "FeedbackUnliked"
| "FeedbackComponentUnmounted",
) => {
const event = events.feedbackTelemetry({ operation });
void eventing.generateEvent({ event });
},
[eventing],
);
};

Просмотреть файл

@ -5,11 +5,45 @@ import * as React from "react";
import "@testing-library/jest-dom";
import { executePlayFunction } from "../../testing-utils/executePlayFunction";
import { prepareStoryContextForTest } from "@nova/react-test-utils/relay";
import type { NovaEventing, EventWrapper } from "@nova/types";
const { ArtificialFailureToShowcaseDecoratorBehaviorInCaseOfADevCausedError } =
const { ArtificialFailureToShowcaseDecoratorBehaviorInCaseOfADevCausedError, Primary } =
composeStories(stories);
const bubbleMock = jest.fn<NovaEventing, [EventWrapper]>();
const generateEventMock = jest.fn<NovaEventing, [EventWrapper]>();
jest.mock("@nova/react", () => ({
...jest.requireActual("@nova/react"),
useNovaEventing: () => ({
bubble: bubbleMock,
generateEvent: generateEventMock,
}),
}));
beforeEach(() => {
jest.clearAllMocks();
});
describe("Feedback", () => {
it("rerenders without unmounting", async () => {
const { rerender, findByRole } = render(<Primary />);
await findByRole("button", { name: "Like" });
let telemetryEvents = generateEventMock.mock.calls.filter(
([{ event }]) => event.type === "feedbackTelemetry",
);
expect(telemetryEvents).toHaveLength(1); // coming from strict mode
rerender(<Primary />);
await findByRole("button", { name: "Like" });
telemetryEvents = generateEventMock.mock.calls.filter(
([{ event }]) => event.type === "feedbackTelemetry",
);
expect(telemetryEvents).toHaveLength(1); // there are no remounts of the component on Story rerender
});
it("throws an error when the developer makes a mistake", async () => {
const { container } = render(
<ArtificialFailureToShowcaseDecoratorBehaviorInCaseOfADevCausedError />,

Просмотреть файл

@ -1,13 +1,11 @@
import {
graphql,
useFragment,
useMutation,
useNovaEventing,
} from "@nova/react";
import { graphql, useFragment, useMutation } from "@nova/react";
import * as React from "react";
import type { FeedbackComponent_LikeMutation } from "./__generated__/FeedbackComponent_LikeMutation.graphql";
import type { Feedback_feedbackFragment$key } from "./__generated__/Feedback_feedbackFragment.graphql";
import { events } from "../../events/events";
import {
useOnDeleteFeedback,
useFeedbackTelemetry,
} from "../../events/helpers";
type Props = {
feedback: Feedback_feedbackFragment$key;
@ -46,6 +44,12 @@ export const FeedbackComponent = (props: Props) => {
const feedbackTelemetry = useFeedbackTelemetry();
React.useEffect(() => {
return () => {
feedbackTelemetry("FeedbackComponentUnmounted");
};
}, []);
return (
<div
style={{
@ -96,27 +100,3 @@ export const FeedbackComponent = (props: Props) => {
</div>
);
};
const useOnDeleteFeedback = (feedbackId: string, feedbackText: string) => {
const eventing = useNovaEventing();
return React.useCallback(
(reactEvent: React.SyntheticEvent) => {
const event = events.onDeleteFeedback({ feedbackId, feedbackText });
void eventing.bubble({ event, reactEvent });
},
[eventing, feedbackId, feedbackText],
);
};
const useFeedbackTelemetry = () => {
const eventing = useNovaEventing();
return React.useCallback(
(operation: "FeedbackLiked" | "FeedbackUnliked") => {
const event = events.feedbackTelemetry({ operation });
void eventing.generateEvent({ event });
},
[eventing],
);
};

Просмотреть файл

@ -1,12 +1,12 @@
import { composeStories } from "@storybook/react";
import * as stories from "./FeedbackContainer.stories";
import { render, screen, waitFor } from "@testing-library/react";
import { render, screen } from "@testing-library/react";
import * as React from "react";
import "@testing-library/jest-dom";
import { prepareStoryContextForTest } from "@nova/react-test-utils/relay";
import { executePlayFunction } from "../../testing-utils/executePlayFunction";
import type { NovaEventing, EventWrapper } from "@nova/types";
import { eventTypes, originator } from "../../events/events";
import { eventTypes, type FeedbackTelemetryEvent } from "../../events/events";
const bubbleMock = jest.fn<NovaEventing, [EventWrapper]>();
const generateEventMock = jest.fn<NovaEventing, [EventWrapper]>();
@ -54,14 +54,16 @@ describe("FeedbackContainer", () => {
const button = await screen.findByRole("button", { name: "Unlike" });
expect(button).toBeInTheDocument();
await waitFor(() => expect(generateEventMock).toHaveBeenCalledTimes(1));
const telemetryEvents = generateEventMock.mock.calls
.filter(([{ event }]) => event.type === eventTypes.feedbackTelemetry)
.map(([{ event }]) => event as FeedbackTelemetryEvent);
const receivedEvent = generateEventMock.mock.calls[0][0].event;
expect(receivedEvent.type).toEqual(eventTypes.feedbackTelemetry);
expect(receivedEvent.originator).toEqual(originator);
expect(receivedEvent.data?.()).toEqual({ operation: "FeedbackLiked" });
const unlikeEvents = telemetryEvents.filter(
(event) => event.data?.().operation === "FeedbackLiked",
);
expect(unlikeEvents).toHaveLength(1);
});
it("should show an error if the like button fails", async () => {
const { container } = render(<LikeFailure />);
await executePlayFunction(

Просмотреть файл

@ -5,13 +5,48 @@ import * as React from "react";
import "@testing-library/jest-dom";
import { executePlayFunction } from "../../testing-utils/executePlayFunction";
import { prepareStoryContextForTest } from "@nova/react-test-utils/relay";
import type { NovaEventing, EventWrapper } from "@nova/types";
const {
ArtificialFailureToShowcaseDecoratorBehaviorInCaseOfADevCausedError,
LikeFailure,
Primary,
} = composeStories(stories);
const bubbleMock = jest.fn<NovaEventing, [EventWrapper]>();
const generateEventMock = jest.fn<NovaEventing, [EventWrapper]>();
jest.mock("@nova/react", () => ({
...jest.requireActual("@nova/react"),
useNovaEventing: () => ({
bubble: bubbleMock,
generateEvent: generateEventMock,
}),
}));
beforeEach(() => {
jest.clearAllMocks();
});
describe("Feedback", () => {
it("rerenders without unmounting", async () => {
const { rerender, findByRole } = render(<Primary />);
await findByRole("button", { name: "Like" });
let telemetryEvents = generateEventMock.mock.calls.filter(
([{ event }]) => event.type === "feedbackTelemetry",
);
expect(telemetryEvents).toHaveLength(1); // coming from strict mode
rerender(<Primary />);
await findByRole("button", { name: "Like" });
telemetryEvents = generateEventMock.mock.calls.filter(
([{ event }]) => event.type === "feedbackTelemetry",
);
expect(telemetryEvents).toHaveLength(1); // there are no remounts of the component on Story rerender
});
it("should show an error if the like button fails", async () => {
const { container } = render(<LikeFailure />);
await executePlayFunction(

Просмотреть файл

@ -1,9 +1,11 @@
import { graphql, useFragment, useMutation } from "react-relay";
import { useNovaEventing } from "@nova/react";
import * as React from "react";
import type { FeedbackComponent_RelayLikeMutation } from "./__generated__/FeedbackComponent_RelayLikeMutation.graphql";
import type { Feedback_feedbackRelayFragment$key } from "./__generated__/Feedback_feedbackRelayFragment.graphql";
import { events } from "../../events/events";
import {
useOnDeleteFeedback,
useFeedbackTelemetry,
} from "../../events/helpers";
type Props = {
feedback: Feedback_feedbackRelayFragment$key;
@ -42,6 +44,12 @@ export const FeedbackComponent = (props: Props) => {
const feedbackTelemetry = useFeedbackTelemetry();
React.useEffect(() => {
return () => {
feedbackTelemetry("FeedbackComponentUnmounted");
};
}, []);
return (
<div
style={{
@ -93,27 +101,3 @@ export const FeedbackComponent = (props: Props) => {
</div>
);
};
const useOnDeleteFeedback = (feedbackId: string, feedbackText: string) => {
const eventing = useNovaEventing();
return React.useCallback(
(reactEvent: React.SyntheticEvent) => {
const event = events.onDeleteFeedback({ feedbackId, feedbackText });
void eventing.bubble({ event, reactEvent });
},
[eventing, feedbackId, feedbackText],
);
};
const useFeedbackTelemetry = () => {
const eventing = useNovaEventing();
return React.useCallback(
(operation: "FeedbackLiked" | "FeedbackUnliked") => {
const event = events.feedbackTelemetry({ operation });
void eventing.generateEvent({ event });
},
[eventing],
);
};

Просмотреть файл

@ -63,45 +63,42 @@ export type WithNovaEnvironment<
);
};
export function getRenderer(
{
query,
variables = {},
referenceEntries,
}: WithNovaEnvironment["novaEnvironment"],
context: Context,
getStory: Addon_LegacyStoryFn,
): React.FC<React.PropsWithChildren<unknown>> {
type RendererProps = {
params: WithNovaEnvironment["novaEnvironment"];
context: Context;
getStory: Addon_LegacyStoryFn;
};
const Renderer: React.FC<RendererProps> = ({
params: { query, variables = {}, referenceEntries = {} },
context,
getStory,
}) => {
if (query) {
const Renderer: React.FC<unknown> = () => {
const { data, error } = useLazyLoadQuery(
// There are no consequences of the cast, we do it only to make sure pure relay components can also leverage the decorator
query as GraphQLTaggedNode,
variables,
);
const { data, error } = useLazyLoadQuery(
// There are no consequences of the cast, we do it only to make sure pure relay components can also leverage the decorator
query as GraphQLTaggedNode,
variables,
);
// apollo does not suspend, but returns undefined data
if (!data) {
if (error) {
// apollo returns an error, while Relay throws, let's align the behavior
throw error;
}
return <div>Loading...</div>;
// apollo does not suspend, but returns undefined data
if (!data) {
if (error) {
// apollo returns an error, while Relay throws, let's align the behavior
throw error;
}
return <div>Loading...</div>;
}
const entries = Object.entries(referenceEntries).map(
([key, getValue]) => [key, getValue(data)] as const,
);
Object.assign(context.args, Object.fromEntries(entries));
return <>{getStory(context)}</>;
};
return () => <Renderer />;
const entries = Object.entries(referenceEntries).map(
([key, getValue]) => [key, getValue(data)] as const,
);
Object.assign(context.args, Object.fromEntries(entries));
return <>{getStory(context)}</>;
} else {
return () => {
return <>{getStory(context)}</>;
};
return <>{getStory(context)}</>;
}
}
};
const NAME_OF_ASSIGNED_PARAMETER_IN_DECORATOR =
"novaEnvironmentAssignedParameterValue";
@ -121,7 +118,6 @@ export const getDecorator = <V extends Variant = "apollo">(
const environment = React.useMemo(() => createEnvironment(), []);
const parameters = (settings.parameters ??
{}) as WithNovaEnvironment["novaEnvironment"];
const Renderer = getRenderer(parameters, context, getStory);
if (parameters.enableQueuedMockResolvers ?? true) {
initializeGenerator(parameters, environment);
}
@ -129,7 +125,7 @@ export const getDecorator = <V extends Variant = "apollo">(
context.parameters[NAME_OF_ASSIGNED_PARAMETER_IN_DECORATOR] = environment;
return (
<NovaMockEnvironmentProvider environment={environment}>
<Renderer />
<Renderer params={parameters} context={context} getStory={getStory} />
</NovaMockEnvironmentProvider>
);
},