#14 Add integration tests for answering questions

This commit is contained in:
Christoph Lienhard 2021-01-30 18:15:51 +01:00
parent 8afea80dd2
commit a70dd7d6a5
Signed by: christoph.lienhard
GPG Key ID: 6B98870DDC270884
9 changed files with 560 additions and 44 deletions

View File

@ -0,0 +1,84 @@
import { MockedResponse } from "@apollo/client/testing";
import {
ADD_ANSWER,
AddAnswerResponse,
AddAnswerVariables,
EDIT_ANSWER,
EditAnswerPayload,
EditAnswerResponse,
EditAnswerVariables,
} from "./answer";
import { answersMock } from "../queries/answer.mock";
import { CandidatePosition } from "../../components/CandidatePositionLegend";
import { FullAnswerResponse } from "../queries/answer";
const editAnswerVariables: EditAnswerVariables = {
id: "a22",
text: "New answer",
};
const getEditedAnswerMock = (): EditAnswerPayload | null => {
const originalAnswer = answersMock.find(
(a) => a.id === editAnswerVariables.id
);
return originalAnswer
? {
answer: {
...originalAnswer,
text:
editAnswerVariables.text === undefined
? originalAnswer.text
: editAnswerVariables.text,
position:
editAnswerVariables.position === undefined
? originalAnswer.position
: editAnswerVariables.position,
},
__typename: "UpdateAnswerPayload",
}
: null;
};
export const editAnswerMock: Array<MockedResponse<EditAnswerResponse>> = [
{
request: {
query: EDIT_ANSWER,
variables: editAnswerVariables,
},
result: {
data: {
updateAnswer: getEditedAnswerMock(),
},
},
},
];
const addAnswerVariables: AddAnswerVariables = {
position: CandidatePosition.positive,
questionRowId: 3,
personRowId: 2,
};
const addedAnswerMock: FullAnswerResponse = {
id: "newA",
...addAnswerVariables,
text: addAnswerVariables.text !== undefined ? addAnswerVariables.text : null,
__typename: "Answer",
};
export const addAnswerMock: Array<MockedResponse<AddAnswerResponse>> = [
{
request: {
query: ADD_ANSWER,
variables: addAnswerVariables,
},
result: {
data: {
createAnswer: {
answer: addedAnswerMock,
__typename: "CreateAnswerPayload",
},
},
},
},
];

View File

@ -105,7 +105,6 @@ const addAnswerToQuestion = (
answersByQuestionRowId: (
answerRefs: NodesCacheRefs = { nodes: [] }
): NodesCacheRefs => {
console.log(answerRefs);
return { nodes: [...answerRefs.nodes, newAnswerRef] };
},
},

View File

@ -0,0 +1,133 @@
import { MockedResponse } from "@apollo/client/testing";
import {
AnswerPositionResponse,
FullAnswerResponse,
GET_ALL_QUESTION_ANSWERS,
GET_ANSWER_BY_QUESTION_AND_PERSON,
GetAllQuestionAnswersResponse,
GetAllQuestionAnswersVariables,
GetAnswerByQuestionAndPersonResponse,
GetAnswerByQuestionAndPersonVariables,
QuestionAnswerResponse,
} from "./answer";
import { CandidatePosition } from "../../components/CandidatePositionLegend";
export const answersMock: Array<FullAnswerResponse> = [
{
id: "a12",
text: null,
position: CandidatePosition.neutral,
questionRowId: 1,
personRowId: 2,
__typename: "Answer",
},
{
id: "a22",
text: "Answer 2",
position: CandidatePosition.positive,
questionRowId: 2,
personRowId: 2,
__typename: "Answer",
},
];
const getAnswersForQuestionRowId = (
questionRowId: number
): Array<FullAnswerResponse> => {
return answersMock.filter((answ) => answ.questionRowId === questionRowId);
};
const getAnswersPositionForQuestionRowId = (
questionRowId: number
): Array<AnswerPositionResponse> => {
return getAnswersForQuestionRowId(questionRowId).map((answer) => ({
__typename: answer.__typename,
id: answer.id,
position: answer.position,
}));
};
export const questionAnswersMock: Array<QuestionAnswerResponse> = [
{
id: "q1",
rowId: 1,
title: "Question 1?",
description: "Further information for Q1",
categoryByCategoryRowId: {
id: "c1",
rowId: 1,
title: "Category 1",
__typename: "Category",
},
answersByQuestionRowId: {
nodes: getAnswersPositionForQuestionRowId(1),
__typename: "AnswersConnection",
},
__typename: "Question",
},
{
id: "q2",
rowId: 2,
title: "Question 2?",
description: "Further information for Q2",
categoryByCategoryRowId: null,
answersByQuestionRowId: {
nodes: getAnswersPositionForQuestionRowId(2),
__typename: "AnswersConnection",
},
__typename: "Question",
},
{
id: "q3",
rowId: 3,
title: "Question 3?",
description: null,
categoryByCategoryRowId: null,
answersByQuestionRowId: {
nodes: getAnswersPositionForQuestionRowId(3),
__typename: "AnswersConnection",
},
__typename: "Question",
},
];
export const getAllQuestionAnswersMock: Array<
MockedResponse<GetAllQuestionAnswersResponse>
> = [
{
request: {
query: GET_ALL_QUESTION_ANSWERS,
variables: {
personRowId: 2,
} as GetAllQuestionAnswersVariables,
},
result: {
data: {
allQuestions: {
nodes: questionAnswersMock,
__typename: "QuestionsConnection",
},
},
},
},
];
export const getAnswerByQuestionAndPersonMock: Array<
MockedResponse<GetAnswerByQuestionAndPersonResponse>
> = [
...questionAnswersMock.map((q, index) => ({
request: {
query: GET_ANSWER_BY_QUESTION_AND_PERSON,
variables: {
personRowId: 2,
questionRowId: index + 1,
} as GetAnswerByQuestionAndPersonVariables,
},
result: {
data: {
answerByQuestionRowIdAndPersonRowId:
getAnswersForQuestionRowId(index + 1)[0] || null,
},
},
})),
];

View File

@ -150,7 +150,7 @@ export default function EditAnswerSection(
loading={loading}
/>
<EditAnswerText
remoteText={remoteAnswer?.text || ""}
remoteText={remoteAnswer?.text}
onSaveClick={handleSaveText}
loading={loading}
/>

View File

@ -25,7 +25,7 @@ const useStyles = makeStyles((theme: Theme) =>
);
interface EditAnswerTextSectionProps {
remoteText: string;
remoteText: string | null | undefined;
loading?: boolean;
onSaveClick(text: string): void;
@ -35,7 +35,8 @@ export default function EditAnswerText(
props: EditAnswerTextSectionProps
): React.ReactElement {
const classes = useStyles();
const [answerText, setAnswerText] = useState<string>(props.remoteText);
const initialAnswer = props.remoteText || "Antwort hinzufügen...";
const [answerText, setAnswerText] = useState<string>(initialAnswer);
return (
<div className={classes.root}>
@ -56,7 +57,7 @@ export default function EditAnswerText(
<ButtonWithSpinner
color="default"
className={classes.button}
onClick={() => setAnswerText(props.remoteText)}
onClick={() => setAnswerText(initialAnswer)}
loading={props.loading}
>
Zurücksetzen

View File

@ -1,15 +1,7 @@
import { Container, Paper, Typography } from "@material-ui/core";
import { Container } from "@material-ui/core";
import React from "react";
import { makeStyles } from "@material-ui/core/styles";
import { useQuery } from "@apollo/client";
import {
GET_ALL_QUESTION_ANSWERS,
GetAllQuestionAnswersResponse,
GetAllQuestionAnswersVariables,
} from "../backend/queries/answer";
import { getJsonWebToken } from "../jwt/jwt";
import AccordionQuestionAnswer from "./AccordionQuestionAnswer";
import { CandidatePositionLegend } from "./CandidatePositionLegend";
import QuestionAnswersList from "./QuestionAnswerList";
const useStyles = makeStyles((theme) => ({
container: {
@ -17,12 +9,6 @@ const useStyles = makeStyles((theme) => ({
paddingBottom: theme.spacing(4),
flexDirection: "column",
},
root: {
width: "100%",
padding: theme.spacing(1),
marginBottom: theme.spacing(3),
backgroundColor: theme.palette.background.default,
},
}));
interface MainPageCandidateProps {
@ -32,32 +18,10 @@ interface MainPageCandidateProps {
export function MainPageCandidate(
props: MainPageCandidateProps
): React.ReactElement {
const personRowId = getJsonWebToken()?.person_row_id;
const questionAnswers = useQuery<
GetAllQuestionAnswersResponse,
GetAllQuestionAnswersVariables
>(GET_ALL_QUESTION_ANSWERS, {
variables: {
personRowId,
},
}).data?.allQuestions.nodes;
const classes = useStyles();
return (
<Container maxWidth="lg" className={classes.container}>
<Paper className={classes.root}>
<Typography component={"h2"} variant="h6" color="primary" gutterBottom>
Fragen
</Typography>
<CandidatePositionLegend />
{questionAnswers?.map((question) => (
<AccordionQuestionAnswer
key={question.rowId}
personRowId={props.personRowId}
question={question}
/>
))}
</Paper>
<QuestionAnswersList personRowId={props.personRowId} />
</Container>
);
}

View File

@ -0,0 +1,54 @@
import React from "react";
import { useQuery } from "@apollo/client";
import {
GET_ALL_QUESTION_ANSWERS,
GetAllQuestionAnswersResponse,
GetAllQuestionAnswersVariables,
} from "../backend/queries/answer";
import { Paper, Typography } from "@material-ui/core";
import { CandidatePositionLegend } from "./CandidatePositionLegend";
import AccordionQuestionAnswer from "./AccordionQuestionAnswer";
import { makeStyles } from "@material-ui/core/styles";
const useStyles = makeStyles((theme) => ({
root: {
width: "100%",
padding: theme.spacing(1),
marginBottom: theme.spacing(3),
backgroundColor: theme.palette.background.default,
},
}));
interface QuestionAnswerListProps {
personRowId: number;
}
export default function QuestionAnswersList(
props: QuestionAnswerListProps
): React.ReactElement {
const classes = useStyles();
const questionAnswers = useQuery<
GetAllQuestionAnswersResponse,
GetAllQuestionAnswersVariables
>(GET_ALL_QUESTION_ANSWERS, {
variables: {
personRowId: props.personRowId,
},
}).data?.allQuestions.nodes;
return (
<Paper className={classes.root}>
<Typography component={"h2"} variant="h6" color="primary" gutterBottom>
Fragen
</Typography>
<CandidatePositionLegend />
{questionAnswers?.map((question) => (
<AccordionQuestionAnswer
key={question.rowId}
personRowId={props.personRowId}
question={question}
/>
))}
</Paper>
);
}

View File

@ -0,0 +1,252 @@
import React from "react";
import {
findByRole,
findByText,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { MockedProvider, MockedResponse } from "@apollo/client/testing";
import { MemoryRouter } from "react-router-dom";
import { SnackbarProvider } from "notistack";
import {
getAllQuestionAnswersMock,
getAnswerByQuestionAndPersonMock,
questionAnswersMock,
} from "../backend/queries/answer.mock";
import {
addAnswerMock,
editAnswerMock,
} from "../backend/mutations/answer.mock";
import {
getNegativePositionPath,
getNeutralPositionPath,
getPositivePositionPath,
getSkippedPositionPath,
queryAllIconButtons,
} from "./test-helper";
import QuestionAnswersList from "../components/QuestionAnswerList";
describe("The AnswerList", () => {
test("displays the existing answers, but not the details of it", async () => {
renderQuestionAnswerList();
const questionAnswerCards = await waitForQuestionsToRender();
questionAnswerCards.forEach((card) => {
expect(card.innerHTML).toMatch(/Question [1-3]/);
});
const saveButtons = screen.queryAllByRole("button", { name: /speichern/i });
expect(saveButtons).toHaveLength(0);
expect(questionAnswerCards[0].innerHTML).toContain(
getNeutralPositionPath()
);
expect(questionAnswerCards[1].innerHTML).toContain(
getPositivePositionPath()
);
expect(questionAnswerCards[2].innerHTML).toContain(
getSkippedPositionPath()
);
});
test("enables resetting an answer to the last saved state", async () => {
renderQuestionAnswerList(editAnswerMock);
const questionAnswerCards = await waitForQuestionsToRender();
let answerSection = await expandAccordionAndGetAnswerSection(
questionAnswerCards[1]
);
expect(answerSection).toBeDefined();
answerSection = answerSection as AccordionAnswerSection;
const { textField, resetButton } = answerSection;
expect(textField.outerHTML).toContain("Answer 2");
// change answer title
fireEvent.change(textField, {
target: { value: "New answer" },
});
await waitFor(() => {
expect(textField.outerHTML).toContain("New answer");
});
// reset and verify
fireEvent.click(resetButton);
await waitFor(() => {
expect(textField.outerHTML).toContain("Answer 2");
});
});
test("enables editing an answer", async () => {
renderQuestionAnswerList(editAnswerMock);
const questionAnswerCards = await waitForQuestionsToRender();
let answerSection = await expandAccordionAndGetAnswerSection(
questionAnswerCards[1]
);
expect(answerSection).toBeDefined();
answerSection = answerSection as AccordionAnswerSection;
const { textField, saveButton, resetButton } = answerSection;
expect(textField.outerHTML).toContain("Answer 2");
// change answer title
fireEvent.change(textField, {
target: { value: "New answer" },
});
await waitFor(() => {
expect(textField.outerHTML).toContain("New answer");
});
// call backend and assert apollo cache update (i.e a subsequent reset should reset to the new answer)
fireEvent.click(saveButton);
fireEvent.change(textField, {
target: { value: "something else" },
});
await waitFor(() => {
expect(textField.outerHTML).toContain("something else");
});
fireEvent.click(resetButton);
await waitFor(() => {
expect(textField.outerHTML).toContain("New answer");
});
});
test("enables adding an answer via setting the position", async () => {
renderQuestionAnswerList(addAnswerMock);
const questionAnswerCards = await waitForQuestionsToRender();
const questionWithoutAnswerCard = questionAnswerCards[2];
let answerSection = await expandAccordionAndGetAnswerSection(
questionWithoutAnswerCard
);
expect(answerSection).toBeDefined();
answerSection = answerSection as AccordionAnswerSection;
const { positionIconButtons } = answerSection;
expect(positionIconButtons.skipped.outerHTML).toContain(
'aria-pressed="true"'
);
expect(positionIconButtons.positive.outerHTML).toContain(
'aria-pressed="false"'
);
// press "positive" icon button and wait for accordion header to change -> apollo cache update successful
fireEvent.click(positionIconButtons.positive);
await waitFor(() => {
expect(questionAnswerCards[2].innerHTML).toContain(
getPositivePositionPath()
);
});
expect(positionIconButtons.skipped.outerHTML).toContain(
'aria-pressed="false"'
);
expect(positionIconButtons.positive.outerHTML).toContain(
'aria-pressed="true"'
);
});
});
function renderQuestionAnswerList(additionalMocks?: Array<MockedResponse>) {
const initialMocks = [
...getAllQuestionAnswersMock,
...getAnswerByQuestionAndPersonMock,
];
const allMocks = additionalMocks
? [...initialMocks, ...additionalMocks]
: initialMocks;
return render(
<MockedProvider mocks={allMocks}>
<MemoryRouter>
<SnackbarProvider>
<QuestionAnswersList personRowId={2} />
</SnackbarProvider>
</MemoryRouter>
</MockedProvider>
);
}
const waitForQuestionsToRender = async (): Promise<Array<HTMLElement>> => {
const numberOfAnswersInMockQuery = questionAnswersMock.length;
let questionAnswerCards: Array<HTMLElement> = [];
await waitFor(() => {
questionAnswerCards = screen.queryAllByRole("button", {
name: /Question [1-3]/,
});
expect(questionAnswerCards.length).toEqual(numberOfAnswersInMockQuery);
});
return questionAnswerCards;
};
const getSaveAnswerButton = (parent: HTMLElement) => {
return findByRole(parent, "button", { name: /speichern/i });
};
const getResetAnswerButton = (parent: HTMLElement) => {
return findByRole(parent, "button", { name: /zurücksetzen/i });
};
interface PositionIconButtons {
positive: HTMLElement;
neutral: HTMLElement;
negative: HTMLElement;
skipped: HTMLElement;
}
const getCandidatePositionButtons = async (
parent: HTMLElement
): Promise<PositionIconButtons> => {
const labelAboveButtons = await findByText(parent, /Deine Position/i);
const parentElement = labelAboveButtons.parentElement as HTMLElement;
const positive = queryAllIconButtons(
getPositivePositionPath(),
parentElement
)[0];
const neutral = queryAllIconButtons(
getNeutralPositionPath(),
parentElement
)[0];
const negative = queryAllIconButtons(
getNegativePositionPath(),
parentElement
)[0];
const skipped = queryAllIconButtons(
getSkippedPositionPath(),
parentElement
)[0];
expect(positive).toBeDefined();
expect(neutral).toBeDefined();
expect(negative).toBeDefined();
expect(skipped).toBeDefined();
return {
positive,
neutral,
negative,
skipped,
};
};
interface AccordionAnswerSection {
positionIconButtons: PositionIconButtons;
textField: HTMLElement;
saveButton: HTMLElement;
resetButton: HTMLElement;
}
const expandAccordionAndGetAnswerSection = async (
accordionExpandArea: HTMLElement
): Promise<AccordionAnswerSection | undefined> => {
fireEvent.click(accordionExpandArea);
let fullAccordion = accordionExpandArea.parentElement;
expect(fullAccordion).not.toBeNull();
fullAccordion = fullAccordion as HTMLElement;
const textField = await findByRole(fullAccordion, "textbox");
const saveButton = await getSaveAnswerButton(fullAccordion);
const resetButton = await getResetAnswerButton(fullAccordion);
const positionIconButtons = await getCandidatePositionButtons(fullAccordion);
return {
positionIconButtons,
textField,
saveButton,
resetButton,
};
};

View File

@ -9,6 +9,10 @@ import {
import EditIcon from "@material-ui/icons/Edit";
import DeleteIcon from "@material-ui/icons/Delete";
import AddIcon from "@material-ui/icons/Add";
import {
CandidatePosition,
getIconForPosition,
} from "../components/CandidatePositionLegend";
const memoizedGetIconPath = (icon: JSX.Element) => {
const cache: { path?: string } = {};
@ -30,6 +34,31 @@ const memoizedGetIconPath = (icon: JSX.Element) => {
const getEditIconPath = memoizedGetIconPath(<EditIcon />);
const getDeleteIconPath = memoizedGetIconPath(<DeleteIcon />);
const getAddIconPath = memoizedGetIconPath(<AddIcon />);
export const getPositivePositionPath = memoizedGetIconPath(
getIconForPosition(CandidatePosition.positive)
);
export const getNeutralPositionPath = memoizedGetIconPath(
getIconForPosition(CandidatePosition.neutral)
);
export const getNegativePositionPath = memoizedGetIconPath(
getIconForPosition(CandidatePosition.negative)
);
export const getSkippedPositionPath = memoizedGetIconPath(
getIconForPosition(CandidatePosition.skipped)
);
export const queryAllIconButtons = (
iconPath: string,
container?: HTMLElement
): HTMLElement[] => {
return (container
? queryAllByRole(container, "button")
: screen.queryAllByRole("button")
).filter(
(button) =>
button.innerHTML.includes("svg") && button.innerHTML.includes(iconPath)
);
};
// sorry, I found no better way to find a specific icon button...
export const queryAllEditIconButtons = (