使用 Jest 和 Cypress 测试您的 Amplify 应用程序
在本文中,我们将为使用 Amplify Console 部署的 Web 应用程序编写静态、单元、集成和端到端 (e2e) 测试,该应用程序使用 Amplify 生成的 AppSync GraphQL API 来查询、变异和订阅数据。
我们正在测试的应用程序可以在这里找到,带有测试的最终代码可以在这里找到。
介绍
在我们继续之前,如果您不确定不同类型的测试之间有什么区别,或者每种类型的含义,请阅读@kentcdodds的这篇文章(老实说,即使您这样做了,也应该阅读它)。
静态测试不是通过执行代码来完成的,而是通过读取代码、解析代码并尝试查找其中的问题来完成的。我们将使用 TypeScript、ESLint 和 Prettier 进行静态测试。
单元测试确保各个代码单元(函数、组件、类……)针对给定的输入产生正确的输出(和效果)。我们将对应用的 React Reducer 进行单元测试,它是一个纯函数(确定性且无副作用)。
集成测试让我们确信不同的代码单元能够按照预期协同工作。我们将使用React 测试库测试我们的路由组件。
最后,端到端测试会像最终用户一样与我们的应用进行交互。
我们将构建代码,然后使用Cypress和Cypress 测试库进行交互并运行断言。
静态测试
打字稿
我们正在测试的应用使用了 Next.js。从版本 9 开始,Next.js 即开即用地支持 TypeScript,无需任何配置(更多信息)。
如果您从头开始运行:
npx create-next-app --example with-typescript
因此,我们只需用 TypeScript 编写代码并运行 TypeScript 编译器来验证每次推送之前没有错误。
为此,我们需要添加一个 git hook,它在每次推送之前运行 TypeScript 编译器,并在代码编译出现错误时阻止我们推送。
Husky使得添加和配置 git hooks 变得简单。
我们首先添加 husky 作为开发依赖项:
npm i -D husky # Or yarn add -D husky
然后在 中package.json
添加一个配置了 git hooks 的 Husky 部分
{
"husky": {
"pre-push": "tsc"
}
}
这就是 TypeScript 的全部,现在,每当我们尝试推送无法编译的代码时,Husky 都会抛出异常并阻止我们这样做。
ESLint
从2019 年开始,ESLint 已获得 TypeScript 的全面支持。TSLint即将被弃用,取而代之的是 ESLint,因此在新项目中使用 ESLint 可能是更明智的选择。
为此,我们首先使用 JavaScript 设置 ESLint,然后添加 TypeScript 支持
首先安装 eslint、eslint react 插件和 typescript 解析器
yarn add -D eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin eslint-plugin-react # npm i -D eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin eslint-plugin-react
然后使用.eslintrc.js
项目根目录中的配置文件初始化 eslint:
module.exports = {
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react/recommended"
],
parserOptions: {
ecmaFeatures: {
jsx: true,
modules: true
},
ecmaVersion: 2018,
sourceType: "module"
},
parser: "@typescript-eslint/parser",
plugins: ["react"],
rules: {
// I usually turn off these rules out of personal, feel free to delete the rules section in your project
"@typescript-eslint/explicit-function-return-type": "off",
"react/prop-types": "off"
}
};
要检查代码,请运行:
# Lint all ts or tsx files in src/ and src/{any}/
yarn eslint src/**/*.ts* src/*.ts* # or $(npm bin)/eslint src/**/*.ts* src/*.ts
或者添加脚本来package.json
运行命令:
{
"scripts": {
"lint": "eslint src/**/*.ts* src/*.ts*"
}
}
由于该项目使用 Amplify Codegen,我们需要告诉 eslint 忽略使用.eslintignore
文件由 cli 发出的生成代码。
正如名称所示,它的行为类似于.gitignore
eslint。
# Path to code generated by Amplify
src/graphql/
src/API.ts
最后,为你的编辑器下载并安装一个 eslint 插件,这样在输入代码时就能看到警告和错误信息。如果你使用的是 VSCode,请点击此处获取插件链接。
Prettier
使用 Prettier 是一件轻而易举的事,它也算是一种静态测试,因为它可以解析代码并在无法解析时抛出错误。
yarn add -D prettier # npm i -D prettier
然后将更漂亮的内容添加到您的代码编辑器中,再也不用考虑格式化了。
最终的 git hooks 变成package.json
:
{
"husky": {
"pre-commit": "prettier --write \"src/*.ts\" \"src/**/*.ts*\"",
"pre-push": "tsc && yarn lint"
}
}
请注意,这会在您的整个代码库上进行 lint 并运行得更漂亮,如果您正在处理大型代码库,那么使用lint-staged仅验证更改的文件可能是一个好主意。
使用 TypeScript 和 ESLint 设置 Jest
有两种方法可以设置 Jest 和 TypeScript:您可以使用 Babel 在运行代码之前剥离类型(无需类型检查),或者使用 TypeScript 编译器在运行代码之前编译。官方文档似乎建议用户使用 Babel,而且 Jest 使用 Babel 比 ts-jest 使用 tsc 更快。因此,我们将使用 Babel 并使用预提交钩子来处理类型检查。
1. 使用 Babel 设置 Jest
跑步
yarn add -D jest @types/jest babel-jest @babel/core @babel/preset-env @babel/preset-react
babel.config.js
在根目录中创建一个文件并在其中添加:
module.exports = {
presets: [
[
"@babel/preset-env",
{
targets: {
node: "current"
}
}
],
"@babel/preset-react"
]
};
2. 为 Babel 添加 TypeScript 支持
yarn add -D @babel/preset-typescript
以及babel.config.js
:
- "@babel/preset-react"
+ "@babel/preset-react",
+ "@babel/preset-typescript"
3. 使用 Jest 配置 ESLint
安装eslint-plugin-jest
yarn add -D eslint-plugin-jest # npm i -D eslint-plugin-jest
并在 .eslintrc.js 文件中,添加 jest 插件和 jest 全局变量(describe、test、expect...):
module.exports = {
env: {
browser: true,
- es6: true
+ es6: true,
+ "jest/globals": true
},
- plugins: ["@typescript-eslint", "react"],
+ plugins: ["@typescript-eslint", "react", "jest"],
}
此时,Jest 应该使用 ESLint 和 TypeScript 正确设置。
运行测试包括在__tests__
目录中添加 TS 文件并执行:
yarn jest # $(npm bin)/jest # npx jest
单元测试
单元测试确保函数在某些输入下能够按照预期运行。
纯函数非常适合单元测试。
我们使用的 React Reducer 包含应用程序的主要逻辑,它是一个纯函数。对于每个给定的状态和动作组合,该函数都会返回一个新的状态。
Jest 是一个注重简单性的测试框架,可用于单元和集成测试。
测试 Reducer
测试 Reducer 函数包括使用不同的操作和状态调用 Reducer 并对输出运行断言。
我们将每个测试定义为以下类型:
type ReducerTest = {
state: State;
action: Action;
assertions: (newState: State, state: State, action: Action) => void;
};
例如,确保添加频道有效的简单测试如下所示:
import cases from "jest-in-case";
const reducerTest = {
name: "Can append channel to empty state"
state: getInitialState(),
action: {
type: "append-channels",
payload: { items: [createChannel()], nextToken: null }
},
assertions: (newState, state, action) => {
expect(newState.channels.items.length).toEqual(1);
}
};
const tests = [reducerTest];
const runTest = reducerTest => {
const newState = reducer(reducerTest.state, reducerTest.action);
reducerTest.assertions(newState, reducerTest.state, reducerTest.action);
};
cases("works", runTest, tests);
添加测试包括将项目添加到测试数组中。
jest-in-case是一个“用于创建相同测试变体的 Jest 实用程序”
您可以在此处找到更多测试。
集成测试
这些将使我们确信我们的组件能够按预期协同工作。我们将对路由组件进行测试和运行断言。
但在此之前我们需要设置模拟。
选择要模拟的内容
模拟包括用具有相同 API 但效果不同的另一个代码单元替换一个代码单元。
例如,假设我们想要模拟来自的 API 对象@aws-amplify/api
。
该应用程序仅使用graphql
API 的方法和 graphqlOperation 方法,因此模拟它就足够了。
@aws-amplify/api
是一个 npm 模块,为了模拟它,我们需要__mocks__
在根目录中添加一个文件夹,并在其中创建一个@aws-amplify
名为的文件夹和文件api.ts
。
__mocks__/@aws-amplify/api.ts
看起来像这样:
const API = {
graphql: operation => {
if (isSubscription(operation)) return Observable;
else return Promise;
}
};
export const graphqlOperation = (query, variables) => ({ query, variables });
export default API;
但是在这种低层次上进行模拟将使测试正确的行为变得更加困难。
例如,如果在挂载时,组件调用API.graphql
3 次,一次用于突变,一次用于查询,一次用于订阅。
为了测试它,我们需要使 API.graphql 模拟相对复杂,它需要在每次调用时解析查询并根据它返回适当类型的数据)所以我们将更上一层楼。
我们不会模拟@aws-amplify/api
模块,而是模拟我们的模型。
此应用中的模型是 UI 与远程 API 交互的唯一接口。组件不允许使用@aws-amplify/api
,而是使用与 API 通信的模型,在需要时处理数据,然后使用 Observable 或 Promise 将数据返回给调用者。
例如,为了获得列出所有渠道的承诺,我们可以写如下内容:
在 App.tsx 中
import * as React from "react";
import { models } from "./models/ModelsContext";
const App = () => {
const [channels, setChannels] = React.useState({ items: [], nextToken: "" });
React.useEffect(() => {
models.Channels.getChannels().then(chans => {
setChannels(c => ({
items: [...c.items, ...chans.items],
nextToken: chans.nextToken
}));
});
}, []);
const loadMore = () => {
models.Channels.getChannels(channels.nextToken).then(chans => {
setChannels(c => ({
items: [...c.items, ...chans.items],
nextToken: chans.nextToken
}));
});
};
return (
<Some>
<ReactTree
onEndReached={() => {
loadMore();
}}
>
{channels.items.map(chan => (
<ChannelCard channel={chan} />
))}
</ReactTree>
</Some>
);
};
在 models/Channels.tsx 中:
import API, { graphqlOperation } from "@aws-amplify/api";
import { queryToGetChannels } from "path/to/generated/graphql/queries";
const EMPTY_CHANNELS = { items: [], nextToken: "" }
export const getChannels = async () => {
try {
const channels = await API.graphql(graphqlOperation(queryToGetChannels));
if (isValidChannelsData(channels))){
return channels;
}
return EMPTY_CHANNELS;
} catch (err) {
return EMPTY_CHANNELS;
}
};
如果您不确定该交易是什么
nextToken
以及如何使用它,请参阅我之前关于使用 AWS Amplify
进行分页和排序的博客文章。
如果 Amplify API 按预期工作,模拟模型将使我们相信应用程序可以正常工作,这对于集成测试来说已经足够了。
除了模型之外,依赖于 JSDOM 中不可用的浏览器功能的依赖项也应该被模拟。这类依赖项只有两个:react-intersection-observer
一个依赖于 IntersectionObserver API,next/router
另一个在 JSDOM 环境中返回空路由器。模拟前者应该很简单,因为它是一个简单的 React hook;模拟后者更简单,因为它只是一个 useContext 调用。
从 next/router模拟useRouter
如果你看一下useRouter 的代码,它只是React.useContext
对路由器上下文的调用:
import { RouterContext } from "next-server/dist/lib/router-context";
export function useRouter() {
return React.useContext(RouterContext);
}
因此,我们不需要使用 Jest 模拟 useRouter,我们只需要将测试包装在新的 RouterContext.Provider 中,并且子组件将在每次测试中注入一个自定义路由器。
import { RouterContext } from "next-server/dist/lib/router-context";
render(
<RouterContext.Provider
value={{
pathname: "/",
push: jest.fn()
//...
}}
>
<App />
</RouterContext.Provider>
);
现在,应用程序在调用时将可以访问上面上下文提供的对象useRouter()
。
如果您以前没有使用过 Context,请务必阅读有关 Context 的 React 文档。
模拟react-intersection-observer
使用 Jest 模拟 npm 依赖非常简单:
__mocks__
在根目录中创建一个名为的文件夹。- 添加一个名为 的文件
react-intersection-observer.ts
。 - 它内部模拟模块的行为。
在__mocks__/react-intersection-observer.ts
。
import * as React from "react";
export const useInView = jest.fn().mockImplementation(() => {
return [React.useRef(), true];
});
export default {
useInView
};
jest.fn()
是一个很好的 Jest 实用函数,用于创建可定制、可覆盖和可检查的模拟函数。
使用 useInView 的组件的示例测试如下所示:
组件:
import * as React from "react";
// When running this code in our tests, the import will be replaced with the code from __mocks/react-intersection-observer
import { useInView } from "react-intersection-observer";
export const Comp = () => {
const [ref, inView] = useInView();
return <div ref={ref}>{inView ? "Visible" : "Hidden"}</div>;
};
测试:
import * as React from "react";
import { render } from "@testing-library/react";
import { useInView } from "../__mocks__/react-intersection-observer";
import { Comp } from "../components/Comp";
describe("Comp with use-in-view", () => {
test("is displayed when inView true", () => {
useInView.mockImplementation(() => {
return [React.useRef(), true];
});
const { getByText } = render(<ComponentUsingInView />);
getByText("Visible");
});
test("is hidden when inView false", () => {
useInView.mockImplementation(() => {
return [React.useRef(), false];
});
const { getByText } = render(<ComponentUsingInView />);
getByText("Hidden");
});
});
@testing-library/react
点击此处了解更多信息。
使用模拟模型测试应用程序
使用 Jest 模拟用户模块类似于模拟节点模块:
__mocks__
在您想要模拟的文件或目录的同一目录中创建一个名为 的文件夹。- 在里面
__mocks__
添加一个与您想要模拟的文件同名的文件。 - 如果测试代码也使用模拟,则在运行测试之前通过调用以下命令进行设置
jest.mock('./path/to/module')
与 Amplify API 交互的模型将返回 Promise(用于查询和变异)或 Observable(用于订阅)。
一旦 Promise 解析成功,或者可观察对象发出值,我们就会更新状态以反映变化。例如,getChannels
解析成功后,应用代码会触发状态更新以显示新数据。
应用程序的 UI 在这些 Promise/Observable 解析/发出之前和之后看起来往往会有所不同。如果能够在解析/发出之前和之后运行断言就好了。
const { getAllByLabelText } = render(<Component />);
const allChannels = getAllByLabelText("channel");
// before getChannels resolves
expect(allChannels.length).toEqual(0);
// Do something here 👇 to resolve getChannels
// ...
// after getChannels resolves
expect(allChannels.length).toEqual(4);
为此,我们需要为每个测试或测试套件提供自定义模拟,以满足这些承诺和可观察对象的需求。
承诺返回方法
模型的模拟是简单的 Jest 模拟函数。正确的实现和数据则留给测试套件来提供。
例如,getChannels
模拟是一行代码src/models/__mocks__/Channels.ts
:
export const getChannels = jest.fn();
在__tests__/channels.test.tsx
渲染我们的组件之前,我们将为这个模拟提供正确的行为:
import * as React from "react";
import { act } from "react-dom/test-utils";
import { render } from "@testing-library/react";
import { getChannels } from "../src/models/__mocks__/Channels.ts";
const dataBank = {
channels: () => [
{
id: "channel-1"
//,...otherFields
}
]
};
type TestUtils = ReturnType<typeof render>
const selectors = {
channelList: (testUtils:TestUtils) => testUtils.getAllByTestId("Channel Card");
}
describe("channels", () => {
let resolveGetChannels;
getChannels.mockImplementation(() => {
return new Promise(resolve => {
// Now a test can resolve getChannels whenever and with any data
resolveGetChannels = resolve;
});
});
test("works", async () => {
const testUtils = render(<Channels />);
// Expect getChannels to be called ( it's called on mount )
expect(getChannels.toBeCalled());
// And getChannels hasn't resolved yet because we haven't called resolveGetChannels
expect(() => {
selectors.channelList(testUtils)
}).toThrow();
// Wait for promise to resolve and ui to update
await act(async () => {
resolveGetChannels(dataBank.channels());
});
// Make sure that channels are visible
expect(selectors.channelList(testUtils).length).toEqual(1);
});
});
如果你不确定它act
是什么,或者它在做什么,那么请阅读@threepointone 的精彩解释
可观察的返回方法
像承诺返回模型一样,我们首先将方法定义为:
export const onCreateChannel = jest.fn();
我们将在测试套件中定义正确的实现。
对于 GraphQL 订阅,AWS Amplify API 库会返回一个 Observable 对象。该库使用zen-observable
来创建可观察对象。但这只是一个实现细节,我们可以使用 RxJS 或任何其他 Observable 实现来模拟返回类型。
如果你还没有使用过 RxJS 或 Observables,你只需要将 Observable 视为一个 Promise,
- 可以解决多次。
- 可以使用
subscribe
代替 来收听then
。
这只是基于本文所需内容的简化。
如需更深入的解释,请参阅Andre Staltz的《你一直错过的响应式编程入门》。
// Creating a promise that is invoked after {ms}ms
const delay = ms => {
return new Promise(resolve => {
setTimeout(resolve, ms);
});
};
// Creating an observable that emits every {ms}ms
const interval = ms => {
return new Observable(observer => {
setInterval(() => observer.next(), ms);
});
};
// Getting the resolved value from a promise
// Fires only once
delay(10).then(value => {});
// Getting the resolved value from a observable
// Fires indefinitely
interval(1000).subscribe(value => {});
在我们的测试中,我们将要劫持observer.next方法,并将其提供给单独的测试,以便在需要时调用:
import * as React from "react";
import { act } from "react-dom/test-utils";
import { Observable } from "rxjs"; // or 'zen-observable'
import { render } from "@testing-library/react";
import { onCreateChannel } from "../src/models/__mocks__/Channels.ts";
const dataBank = {
channel: () => ({
id: "channel-1"
//,...otherFields
})
};
describe("channels", () => {
let emitOnCreateChannel;
onCreateChannel.mockImplementation(() => {
return new Observable(observer => {
// Now a test can emit new channels whenever and with any data
emitOnCreateChannel = v => observer.next(v);
});
});
test("works", () => {
const { getAllByTestId } = render(<Channels />);
// Expect onCreateChannel to be called ( it's called on mount )
expect(onCreateChannel.toBeCalled());
// The list of channels should be empty before data is fetched with models,
expect(() => {
getAllByTestId("Channel Card");
}).toThrow();
// Wait for the observer to emit and ui to update
act(() => {
emitOnCreateChannel(dataBank.channel());
});
// Make sure that the added channel is visible
expect(getAllByTestId("Channel Card").length).toEqual(1);
});
});
您可以在此处看到更多此类测试。
端到端测试
我们将使用Cypress进行 E2E 测试,因为它具有相对较好的开发体验(在我看来),但如果您需要在多个浏览器中运行测试或者不特别喜欢使用 Cypress,那么testcafe可能更适合您。
准备测试环境
我们将使用 Amplify cli 的内置mock
方法模拟整个 Amplify API 。
确保您拥有的 amplify 版本> = 1.11.0(带有amplify --version
)并且您安装了 java(api mock 使用的 DynamoDBLocal 是一个 java 应用程序)。
在初始化的放大项目中运行:amplify mock api
这将在您的本地计算机上创建应用程序云环境的副本,并更新应用程序配置以指向它(通过更新src/aws-exports.js
)。
运行此命令后,我们可以启动应用程序(npm run dev
),它将以与以前完全相同的方式工作,但将连接到本地数据库而不是远程数据库。
安装支持 TypeScript 的 Cypress 非常简单:
- 安装 Cypress 并初始化它:
yarn add -D cypress && yarn cypress --init
- 安装
add-typescript-to-cypress
:yarn add -D @bahmutov/add-typescript-to-cypress
- 👍将 TypeScript 测试添加到
cypress/integration/
目录
添加测试
E2E 测试应该像用户浏览应用程序一样运行。
我们将@testing-library/cypress
在 Cypress 和 Jest 测试之间共享代码(UI 选择器)。以下是 Cypress 测试套件的示例,该套件确保用户可以读取和编辑其个人资料信息,如下所示:
// Note that the code for our selectors is almost identical to the selectors used with Jest
// This is thanks to @testing-library/react & @testing-library/cypress
// Profile selectors
const profile = {
form: (cypress = cy) => cypress.getByLabelText("Profile Form"),
submit: () => cy.getByLabelText("Profile Form Submit Button"),
username: () => cy.getByLabelText("Username"),
bio: () => cy.getByLabelText("Bio"),
url: () => cy.getByLabelText("Url")
};
// Header selectors
const header = {
root: () => cy.getByLabelText("Header Navigation").should("be.visible"),
me: () =>
header
.root()
.within(() => cy.getByText("My Profile"))
.should("be.visible"),
channels: () =>
header
.root()
.within(() => cy.getByText("Channels"))
.should("be.visible")
};
describe("My Profile", () => {
beforeEach(() => {
cy.visit(BASE_URL);
});
afterEach(() => {
// For video to better capture what happened
cy.wait(1000);
});
it("Can visit profile and set information", () => {
const user = {
name: "Test username",
url: "https://test-url.test",
bio: "Bio Test @ Test BIO"
};
header.me().click();
cy.location("href").should("contain", "/me");
profile.username().type(`${user.name}{enter}`);
cy.title().should("contain", `${user.name}'s Profile`);
profile.bio().type(`${user.bio}{enter}`);
profile.url().type(`${user.url}`);
profile.submit().click();
// Make sure data is persisted between sessions
cy.reload();
profile.username().should("contain.value", user.name);
profile.bio().should("contain.value", user.bio);
profile.url().should("contain.value", user.url);
});
});
您可以在此处查看更多 TypeScript Cypress 测试。
添加测试脚本package.json
回顾一下用于运行不同测试的脚本:
{
"scripts": {
"test:static": "yarn lint && yarn tsc",
"test:jest": "yarn jest",
"test:e2e": "(amplify mock api &) && wait-on http-get://localhost:20002 && kill-port 3000 && (yarn dev &) && wait-on http-get://localhost:3000 && cypress run --env PORT=3000",
"test:e2e:dev": "(amplify mock api &) && wait-on http-get://localhost:20002 && kill-port 3000 && (yarn dev &) && wait-on http-get://localhost:3000 && cypress open --env PORT=3000",
"test": "yarn test:static && yarn test:jest"
},
"hooks": {
"pre-commit": "prettier --write \"src/.ts\" \"src//.ts*\"",
"pre-push": "yarn test"
}
}
每次提交时从 Amplify 控制台运行测试
我们只需要告诉 Amplify Console 在每次提交部署之前运行我们的测试。
为此,我们将添加以下内容amplify.yml
version: 0.1
frontend:
phases:
preBuild:
commands:
- yarn install
build:
commands:
# This makes sure that the commit is not deployed if the tests fail.
- yarn run test && yarn run build
artifacts:
baseDirectory: build
files:
- "/*"
cache:
paths:
- node_modules//*
总结
我们已经向使用 Amplify API 的现有聊天应用程序添加了静态、单元、集成和端到端测试,并在提交和推送代码之前以及在使用 Amplify Console 部署之前在云端使用 git hooks 运行它们。
如果您想深入了解,请确保克隆代码库并在本地试验 Jest 和 Cypress 测试。
干杯!
文章来源:https://dev.to/rakannimer/testing-your-amplify-application-with-jest-and-cypress-1g0i