精通 React 测试驱动开发第二版(五)
原文:
zh.annas-archive.org/md5/5e6d20182dc7eee4198d982cf82680c0译者:飞龙
第十二章:测试驱动 Redux
Redux是一个可预测的状态容器。对于初学者来说,这些词意义不大。幸运的是,TDD 可以帮助我们理解如何思考和实现 Redux 应用架构。本章中的测试将帮助你了解 Redux 如何集成到任何应用中。
Redux 的主要优势是能够在异步浏览器环境中以提供数据一致性的方式在组件之间共享状态。其重大缺点是必须在应用中引入大量管道和复杂性。
这里是龙
对于许多应用来说,Redux 的复杂性超过了其好处。仅仅因为本章存在于这本书中,并不意味着你应该急忙去使用 Redux。实际上,我希望本书中的代码示例足以作为警告,提醒你将要引入的复杂性。
在本章中,我们将构建一个 reducer 和一个 saga 来管理CustomerForm组件的提交。
我们将使用一个名为expect-redux的测试库来测试 Redux 交互。这个库允许我们编写与redux-saga库无关的测试。独立于库是确保测试不脆弱且对变化有弹性的好方法:你可以用redux-thunk替换redux-saga,测试仍然会工作。
本章涵盖了以下主题:
-
为 reducer 和 saga 进行前期设计
-
测试驱动 reducer
-
测试驱动 saga
-
将组件状态切换为 Redux 状态
到本章结束时,你将看到测试 Redux 所需的所有技术。
技术要求
本章的代码文件可以在以下位置找到:
为 reducer 和 saga 进行前期设计
在本节中,我们将像往常一样制定一个粗略的计划,说明我们将要构建什么。
让我们先看看实际的技术变化是什么,并讨论我们为什么要这样做。
我们将把提交客户的逻辑——CustomerForm中的doSave函数——从 React 组件移到 Redux 中。我们将使用 Redux reducer 来管理操作的状态:它是否正在提交、已完成或发生了验证错误。我们将使用 Redux saga 来执行异步操作。
为什么选择 Redux?
考虑到当前的应用功能集,实际上没有理由使用 Redux。然而,想象一下,在未来,我们希望支持以下功能:
-
在添加新客户后,
AppointmentForm组件会在提交之前显示客户信息,而无需从服务器重新获取数据 -
在从
CustomerSearch组件中找到客户并选择创建预约后,相同的客户信息将显示在预约屏幕上,无需重新获取数据
在这个未来的场景中,可能有必要有一个共享的 Redux 状态来存储客户数据。
我说“可能”,因为还有其他可能更简单的解决方案:组件上下文,或者可能某种类型的 HTTP 响应缓存。谁知道解决方案会是什么样子?没有具体要求很难说。
总结一下:在本章中,我们将使用 Redux 来存储客户数据。它没有比我们当前方法更多的实际好处,实际上,它有所有额外管道的缺点。然而,鉴于本书的教育目的,让我们继续前进。
设计存储状态和动作
Redux 存储只是一个具有一些访问限制的数据对象。这是我们希望它看起来的样子。该对象编码了CustomerForm已经使用关于保存客户数据的fetch请求的所有信息:
{
customer: {
status: SUBMITTING | SUCCESSFUL | FAILED | ...
// only present if the customer was saved successfully
customer: { id: 123, firstName: "Ashley" ... },
// only present if there are validation errors
validationErrors: { phoneNumber: "..." },
// only present if there was another type of error
error: true | false
}
}
Redux 通过命名动作来改变这个状态。我们将有以下动作:
-
ADD_CUSTOMER_REQUEST, 当用户按下提交客户按钮时调用。这触发了 saga,然后触发剩余的操作 -
ADD_CUSTOMER_SUBMITTING,当 saga 开始其工作时 -
ADD_CUSTOMER_SUCCESSFUL,当服务器保存客户并返回一个新的客户 ID。使用这个动作,我们还将保存新的客户信息到 reducer 中,以便以后使用 -
ADD_CUSTOMER_VALIDATION_FAILED,如果提供的客户数据无效 -
ADD_CUSTOMER_FAILED,如果服务器由于其他原因无法保存数据
作为参考,以下是现有代码,我们将从CustomerForm中提取这些代码。它都在一个名为doSave的函数中,尽管它相当长:
const doSave = async () => {
setSubmitting(true);
const result = await global.fetch("/customers", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(customer),
});
setSubmitting(false);
if (result.ok) {
setError(false);
const customerWithId = await result.json();
onSave(customerWithId);
} else if (result.status === 422) {
const response = await result.json();
setValidationErrors(response.errors);
} else {
setError(true);
}
};
我们将用 saga 和 reducer 的组合替换所有这些代码。我们将在下一节中开始从 reducer 开始。
测试驱动 reducer
在本节中,我们将测试驱动一个新的 reducer 函数,然后提取一些重复的代码。
一个 reducer 是一个简单的函数,它接受一个动作和当前存储状态作为输入,并返回一个新的状态对象作为输出。现在让我们按照以下方式构建它:
-
创建一个名为
test/reducers/customer.test.js的新文件(在新的目录中)。添加以下第一个测试,该测试检查如果 reducer 被一个未知动作调用,我们的 reducer 应该返回一个默认状态给我们的对象。这是 Redux reducer 的标准行为,所以你应该始终从一个这样的测试开始:import { reducer } from "../../src/reducers/customer"; describe("customer reducer", () => { it("returns a default state for an undefined existing state", () => { expect(reducer(undefined, {})).toEqual({ customer: {}, status: undefined, validationErrors: {}, error: false }); }); }); -
创建一个
src/reducers/customer.js文件,如下所示,并让这个测试通过:const defaultState = { customer: {}, status: undefined, validationErrors: {}, error: false }; export const reducer = (state = defaultState, action) => { return state; }; -
对于下一个测试,添加对
ADD_CUSTOMER_SUBMITTING动作的支持,如下所示。这个测试检查当接收到这个动作时,状态值更新为SUBMITTING:describe("ADD_CUSTOMER_SUBMITTING action", () => { const action = { type: "ADD_CUSTOMER_SUBMITTING" }; it("sets status to SUBMITTING", () => { expect(reducer(undefined, action)).toMatchObject({ status: "SUBMITTING" }); }); }); -
通过用以下代码替换 reducer 的主体来实现这个过渡。我们可以直接使用
switch语句(而不是使用if语句),因为我们确定我们将填充其他动作类型:switch(action.type) { case "ADD_CUSTOMER_SUBMITTING": return { status: "SUBMITTING" }; default: return state; } -
在
ADD_CUSTOMER_SUBMITTINGdescribe块中添加第二个测试,如下所示。这个测试指定了 reducer 动作的预期行为:我们不关心的任何状态(在这个例子中是status)保持不变:it("maintains existing state", () => { expect(reducer({ a: 123 }, action)).toMatchObject({ a: 123 }); }); -
通过修改 reducer 来实现这个过渡,如下所示:
export const reducer = (state = defaultState, action) => { switch (action.type) { case "ADD_CUSTOMER_SUBMITTING": return { ...state, status: "SUBMITTING" }; default: return state; } }; -
我们需要处理
ADD_CUSTOMER_SUCCESSFUL动作。从下面显示的两个测试开始。我通过一次编写两个测试来作弊,但这没关系,因为我知道它们是ADD_CUSTOMER_SUBMITTING测试的近似复制品:describe("ADD_CUSTOMER_SUCCESSFUL action", () => { const customer = { id: 123 }; const action = { type: "ADD_CUSTOMER_SUCCESSFUL", customer }; it("sets status to SUCCESSFUL", () => { expect(reducer(undefined, action)).toMatchObject({ status: "SUCCESSFUL" }); }); it("maintains existing state", () => { expect( reducer({ a: 123 }, action) ).toMatchObject({ a: 123 }); }); }); -
要实现这个过渡,请向您的 reducer 添加一个最后的
case语句,如下所示:case "ADD_CUSTOMER_SUCCESSFUL": return { ...state, status: "SUCCESSFUL" }; -
添加下一个测试,如下所示。动作提供了一个带有分配 ID 的新
customer对象,我们应该将其保存在 reducer 中以供以后使用:it("sets customer to provided customer", () => { expect(reducer(undefined, action)).toMatchObject({ customer }); }); -
通过添加
customer属性来实现这个过渡,如下所示:case "ADD_CUSTOMER_SUCCESSFUL": return { ...state, status: "SUCCESSFUL", customer: action.customer }; -
添加下一个
describe块,用于ADD_CUSTOMER_FAILED,如下所示:describe("ADD_CUSTOMER_FAILED action", () => { const action = { type: "ADD_CUSTOMER_FAILED" }; it("sets status to FAILED", () => { expect(reducer(undefined, action)).toMatchObject({ status: "FAILED" }); }); it("maintains existing state", () => { expect( reducer({ a: 123 }, action) ).toMatchObject({ a: 123 }); }); }); -
通过向
switchreducer 添加一个新的case语句来实现这两个测试的通过,如下所示:case "ADD_CUSTOMER_FAILED": return { ...state, status: "FAILED" }; -
我们还没有完成
ADD_CUSTOMER_FAILED。在这种情况下,我们还想将error设置为true。回想一下,我们在CustomerForm组件中使用了error状态变量来标记何时发生了未解释的错误。我们需要在这里复制它。向describe块添加以下第三个测试:it("sets error to true", () => { expect(reducer(undefined, action)).toMatchObject({ error: true }); }); -
通过修改
case语句来实现这个过渡,如下所示:case "ADD_CUSTOMER_FAILED": return { ...state, status: "FAILED", error: true }; -
为
ADD_CUSTOMER_VALIDATION_FAILED动作添加测试,该动作发生在字段验证失败的情况下。代码如下所示:describe("ADD_CUSTOMER_VALIDATION_FAILED action", () => { const validationErrors = { field: "error text" }; const action = { type: "ADD_CUSTOMER_VALIDATION_FAILED", validationErrors }; it("sets status to VALIDATION_FAILED", () => { expect(reducer(undefined, action)).toMatchObject({ status: "VALIDATION_FAILED" }); }); it("maintains existing state", () => { expect( reducer({ a: 123 }, action) ).toMatchObject({ a: 123 }); }); }); -
通过在 reducer 中添加另一个
case语句来实现这些测试的通过,如下所示:case "ADD_CUSTOMER_VALIDATION_FAILED": return { ...state, status: "VALIDATION_FAILED" }; -
这个动作也需要第三个测试。这次,动作可以包含有关验证错误的错误信息,如下面的代码片段所示:
it("sets validation errors to provided errors", () => { expect(reducer(undefined, action)).toMatchObject({ validationErrors }); }); -
按照下面的更改实现这个过渡:
case "ADD_CUSTOMER_VALIDATION_FAILED": return { ...state, status: "VALIDATION_FAILED", validationErrors: action.validationErrors };
这样就完成了 reducer,但在我们将其用于 saga 之前,我们不妨稍微简化一下这些测试?
提取 reducer 动作的生成函数
大多数 reducer 将遵循相同的模式:每个动作都将设置一些新数据以确保现有状态不会丢失。
让我们编写几个测试生成函数来帮我们完成这个任务,以帮助我们简化测试。按照以下步骤进行:
-
创建一个新文件,
test/reducerGenerators.js,并向其中添加以下函数:export const itMaintainsExistingState = (reducer, action) => { it("maintains existing state", () => { const existing = { a: 123 }; expect( reducer(existing, action) ).toMatchObject(existing); }); }; -
将以下
import语句添加到src/reducers/customer.test.js的顶部:import { itMaintainsExistingState } from "../reducerGenerators"; -
修改您的测试以使用此函数,删除每个
describe块中的测试,并用以下单行替换它:itMaintainsExistingState(reducer, action); -
在
test/reducerGenerators.js中,定义以下函数:export const itSetsStatus = (reducer, action, value) => { it(`sets status to ${value}`, () => { expect(reducer(undefined, action)).toMatchObject({ status: value }); }); }; -
修改现有的
import语句以引入新函数,如下所示:import { itMaintainsExistingState, itSetsStatus } from "../reducerGenerators"; -
修改你的测试以使用此函数,就像你之前做的那样。确保运行你的测试以证明它们可以工作!你的测试现在应该会短得多。以下是一个
describe块的示例,用于ADD_CUSTOMER_SUCCESSFUL:describe("ADD_CUSTOMER_SUBMITTING action", () => { const action = { type: "ADD_CUSTOMER_SUBMITTING" }; itMaintainsExistingState(reducer, action); itSetsStatus(reducer, action, "SUBMITTING"); });
这就完成了 reducer。在我们继续 saga 之前,让我们将其与应用程序连接起来。我们根本不会使用它,但现在建立基础设施是好的。
设置存储和入口点
除了我们编写的 reducer 之外,我们还需要定义一个名为 configureStore 的函数,然后在我们应用程序启动时调用它。按照以下步骤进行:
-
创建一个名为
src/store.js的新文件,并包含以下内容。目前不需要测试这个文件,因为它有点像src/index.js:连接一切的基础设施。然而,我们将在下一节测试 saga 时使用它:import { createStore, combineReducers } from "redux"; import { reducer as customerReducer } from "./reducers/customer"; export const configureStore = (storeEnhancers = []) => createStore( combineReducers({ customer: customerReducer }), storeEnhancers ); -
在
src/index.js中,在文件顶部添加以下两个import语句:import { Provider } from "react-redux"; import { configureStore } from "./store"; -
然后,像下面这样将现有的 JSX 包装在
Provider组件中。这就是我们的所有组件如何获得访问 Redux 存储的权限:ReactDOM.createRoot( document.getElementById("root") ).render( <Provider store={configureStore()}> <BrowserRouter> <App /> </BrowserRouter> </Provider> );
这样一来,我们就准备好编写复杂的部分了:saga。
测试驱动 saga
Saga 是一段特殊的代码,它使用 JavaScript 生成器函数来管理对 Redux 存储的异步操作。因为它非常复杂,我们实际上不会测试 saga 本身;相反,我们将向存储派发一个动作并观察结果。
在我们开始 saga 测试之前,我们需要一个名为 renderWithStore 的新测试辅助函数。
添加 renderWithStore 测试扩展
按照以下步骤进行:
-
在
test/reactTestExtensions.js的顶部,添加以下新的import语句:import { Provider } from "react-redux"; import { storeSpy } from "expect-redux"; import { configureStore } from "../src/store";
expect-redux 包
为了做到这一点,我们将使用 NPM 中的 expect-redux 包,它已经包含在 package.json 文件中供你使用——确保在开始之前运行 npm install。
-
添加一个新的变量
store,并在initializeReactContainer中初始化它,如下代码片段所示。这使用了来自expect-redux的storeSpy,我们将在测试中用它来检查对存储的调用:export let store; export const initializeReactContainer = () => { store = configureStore([storeSpy]); container = document.createElement("div"); document.body.replaceChildren(container); reactRoot = ReactDOM.createRoot(container); }; -
在
renderWithRouter函数下方添加你的新渲染函数,如下代码片段所示:export const renderWithStore = (component) => act(() => reactRoot.render( <Provider store={store}>{component}</Provider> ) ); -
最后,添加
dispatchStore,当我们在组件中开始派发动作时将需要它,如下所示:export const dispatchToStore = (action) => act(() => store.dispatch(action));
现在你已经拥有了开始测试连接到 Redux 存储的 sagas 和组件所需的所有辅助工具。所有这些都已就绪,让我们开始 saga 测试。
使用 expect-redux 编写期望
我们编写的 saga 将响应从 CustomerForm 组件派发的 ADD_CUSTOMER_REQUEST 动作。saga 的功能与本章开头 设计存储状态和动作 部分中列出的 doSave 函数相同。区别在于我们需要使用 saga 的 put、call 等函数调用。
让我们从编写一个名为 addCustomer 的生成器函数开始。按照以下步骤进行:
-
创建一个新文件(在新的目录中),命名为
test/sagas/customer.test.js,并添加以下代码来设置我们的describe块。我们初始化一个store变量,我们的 sagas 和测试期望都将使用它。这是我们在initializeReactContainer测试辅助程序中之前使用的代码的重复——我们在这里不能使用它,因为我们不是在编写组件:import { storeSpy, expectRedux } from "expect-redux"; import { configureStore } from "../../src/store"; describe("addCustomer", () => { let store; beforeEach(() => { store = configureStore([ storeSpy ]); }); }); -
在
beforeEach块下面,添加以下辅助函数,它为我们提供了一种构建动作的更优雅的方式——你将在下一个测试中看到:const addCustomerRequest = (customer) => ({ type: "ADD_CUSTOMER_REQUEST", customer, }); -
现在是第一个测试。我们的 saga 应该首先做什么?它必须更新我们的存储状态,以反映表单正在提交。这样,
CustomerForm组件就可以立即向用户显示提交指示器。我们使用expect-redux的期望来确保我们派发了正确的动作,如下所示:it("sets current status to submitting", () => { store.dispatch(addCustomerRequest()); return expectRedux(store) .toDispatchAnAction() .matching({ type: "ADD_CUSTOMER_SUBMITTING" }); });
从测试中返回承诺
这个测试返回一个承诺。这是一个我们可以使用的快捷方式,而不是将我们的测试函数标记为 async 并使用 await 来设置期望。Jest 知道如果测试函数返回一个承诺,就需要等待。
-
让我们从 saga 实现开始。创建一个名为
src/sagas/customer.js的新文件,并添加以下内容。注意function*语法,它表示一个生成器函数,以及使用put向存储发射另一个动作:import { put } from "redux-saga/effects"; export function* addCustomer() { yield put({ type: "ADD_CUSTOMER_SUBMITTING" }); }
生成器函数语法
我们在整个书中一直在使用的箭头函数语法不适用于生成器函数,因此我们需要回退到使用 function 关键字。
-
在那个测试通过之前,我们需要使用
addCustomersaga 更新存储。从导入语句开始,将src/store.js更改为以下内容:import { createStore, applyMiddleware, compose, combineReducers } from "redux"; import createSagaMiddleware from "redux-saga"; import { takeLatest } from "redux-saga/effects"; import { addCustomer } from "./sagas/customer"; import { reducer as customerReducer } from "./sagas/customer"; -
在那些导入下面,添加以下
rootSaga定义:function* rootSaga() { yield takeLatest( "ADD_CUSTOMER_REQUEST", addCustomer ); } -
现在,更新
configureStore以包括 saga 中间件和“运行”rootSaga,如下所示。在此更改之后,你的测试应该可以通过:export const configureStore = (storeEnhancers = []) => { const sagaMiddleware = createSagaMiddleware(); const store = createStore( combineReducers({ customer: customerReducer }), compose( applyMiddleware(sagaMiddleware), ...storeEnhancers ) ); sagaMiddleware.run(rootSaga); return store; };
这完成了 saga 的第一个测试,并放置了所有必要的管道。你还看到了如何使用 put。接下来,让我们介绍 call。
使用 sagas 进行异步请求
在 saga 中,call 允许我们执行异步请求。现在让我们介绍这一点。按照以下步骤进行:
-
添加以下测试,以检查对
fetch的调用:it("sends HTTP request to POST /customers", async () => { const inputCustomer = { firstName: "Ashley" }; store.dispatch(addCustomerRequest(inputCustomer)); expect(global.fetch).toBeCalledWith( "/customers", expect.objectContaining({ method: "POST", }) ); }); -
为了使这个功能正常工作,我们需要在
global.fetch上定义一个间谍。将beforeEach块更改为如下,包括新的客户常量:beforeEach(() => { jest.spyOn(global, "fetch"); store = configureStore([ storeSpy ]); }); -
在
src/sagas/customer.js中,更新 saga 导入以包括call函数,如下所示:import { put, call } from "redux-saga/effects"; -
现在,创建一个名为
fetch的函数,并在 saga 中使用call来调用它,如下所示。之后,你的测试应该可以通过:const fetch = (url, data) => global.fetch(url, { method: "POST", }); export function* addCustomer({ customer }) { yield put({ type: "ADD_CUSTOMER_SUBMITTING" }); yield call(fetch, "/customers", customer); } -
好吧——现在,让我们添加一个测试来添加我们的
fetch请求的配置,如下所示:it("calls fetch with correct configuration", async () => { const inputCustomer = { firstName: "Ashley" }; store.dispatch(addCustomerRequest(inputCustomer)); expect(global.fetch).toBeCalledWith( expect.anything(), expect.objectContaining({ credentials: "same-origin", headers: { "Content-Type": "application/json" }, }) ); }); -
为了让它通过,请将以下行添加到
fetch定义中:const fetch = (url, data) => global.fetch(url, { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" } }); -
现在,让我们测试一下我们是否正在发送正确的客户数据。以下是我们可以这样做的方法:
it("calls fetch with customer as request body", async () => { const inputCustomer = { firstName: "Ashley" }; store.dispatch(addCustomerRequest(inputCustomer)); expect(global.fetch).toBeCalledWith( expect.anything(), expect.objectContaining({ body: JSON.stringify(inputCustomer), }) ); }); -
为了实现这一点,完成
fetch定义,如下所示:const fetch = (url, data) => global.fetch(url, { body: JSON.stringify(data), method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" } }); -
对于下一个测试,我们希望在
fetch调用成功返回时分派一个ADD_CUSTOMER_SUCCESSFUL事件。它使用一个名为customer的常量,我们将在下一步定义。以下是我们需要执行的代码:it("dispatches ADD_CUSTOMER_SUCCESSFUL on success", () => { store.dispatch(addCustomerRequest()); return expectRedux(store) .toDispatchAnAction() .matching({ type: "ADD_CUSTOMER_SUCCESSFUL", customer }); }); -
在我们之前设置
fetch间谍之前,我们没有设置返回值。因此,现在创建一个customer常量,并设置fetch间谍以返回它,如下所示:const customer = { id: 123 }; beforeEach(() => { jest .spyOn(global, "fetch") .mockReturnValue(fetchResponseOk(customer)); store = configureStore([ storeSpy ]); }); -
按照如下方式导入
fetchResponseOk。在此之后,你将能够运行你的测试:import { fetchResponseOk } from "../builders/fetch"; -
通过处理
call函数的结果,使测试通过,如下所示:export function* addCustomer({ customer }) { yield put({ type: "ADD_CUSTOMER_SUBMITTING" }); const result = yield call(fetch, "/customers", customer); const customerWithId = yield call([result, "json"]); yield put({ type: "ADD_CUSTOMER_SUCCESSFUL", customer: customerWithId }); } -
如果
fetch调用不成功,可能是由于网络故障,该怎么办?添加一个测试,如下所示:it("dispatches ADD_CUSTOMER_FAILED on non-specific error", () => { global.fetch.mockReturnValue(fetchResponseError()); store.dispatch(addCustomerRequest()); return expectRedux(store) .toDispatchAnAction() .matching({ type: "ADD_CUSTOMER_FAILED" }); }); -
该测试使用了
fetchResponseError;现在像这样导入它:import { fetchResponseOk, fetchResponseError } from "../builders/fetch"; -
通过将现有代码包裹在一个带有
else子句的if语句中,使测试通过,如下所示:export function* addCustomer({ customer }) { yield put({ type: "ADD_CUSTOMER_SUBMITTING" }); const result = yield call( fetch, "/customers", customer ); if(result.ok) { const customerWithId = yield call( [result, "json"] ); yield put({ type: "ADD_CUSTOMER_SUCCESSFUL", customer: customerWithId }); } else { yield put({ type: "ADD_CUSTOMER_FAILED" }); } } -
最后,添加一个针对更具体类型的失败的测试——验证失败,如下所示:
it("dispatches ADD_CUSTOMER_VALIDATION_FAILED if validation errors were returned", () => { const errors = { field: "field", description: "error text" }; global.fetch.mockReturnValue( fetchResponseError(422, { errors }) ); store.dispatch(addCustomerRequest()); return expectRedux(store) .toDispatchAnAction() .matching({ type: "ADD_CUSTOMER_VALIDATION_FAILED", validationErrors: errors }); }); -
使用以下代码使测试通过:
export function* addCustomer({ customer }) { yield put({ type: "ADD_CUSTOMER_SUBMITTING" }); const result = yield call(fetch, "/customers", customer); if(result.ok) { const customerWithId = yield call( [result, "json"] ); yield put({ type: "ADD_CUSTOMER_SUCCESSFUL", customer: customerWithId }); } else if (result.status === 422) { const response = yield call([result, "json"]); yield put({ type: "ADD_CUSTOMER_VALIDATION_FAILED", validationErrors: response.errors }); } else { yield put({ type: "ADD_CUSTOMER_FAILED" }); } }
现在 saga 已经完成。将此函数与我们要替换的 CustomerForm 中的函数进行比较:doSave。结构是相同的。这是一个好的迹象,表明我们准备好从 CustomerForm 中移除 doSave。
在下一节中,我们将更新 CustomerForm 以使用我们新的 Redux 存储。
将组件状态切换为 Redux 状态
现在 saga 和 reducer 已经完成并准备好在 CustomerForm React 组件中使用。在本节中,我们将替换 doSave 的使用,然后作为最后的润色,我们将把我们的 React Router 导航推入 saga,从 App 中移除 onSave 回调。
通过分派 Redux 动作提交 React 表单
在本章的开头,我们探讨了这次更改的目的基本上是将 CustomerForm 的 doSave 函数移植到 Redux 动作中。
使用我们新的 Redux 设置,我们使用组件状态来显示提交指示器并显示任何验证错误。这些信息现在存储在 Redux 存储中,而不是组件状态中。因此,除了分派一个替换 doSave 的动作外,组件还需要从存储中读取状态。组件状态变量可以被删除。
这对我们的测试也有影响。由于 saga 测试失败模式,我们的 CustomerForm 组件测试只需要处理 Redux 存储的各种状态,我们将使用我们的 dispatchToStore 扩展来操作这些状态。
我们将首先使我们的组件具有 Redux 意识,如下所示:
-
将以下
import语句添加到test/CustomerForm.test.js的顶部:import { expectRedux } from "expect-redux"; -
更新测试扩展的
import语句,将render替换为renderWithStore,并添加两个新的导入,如下所示:import { initializeReactContainer, renderWithStore, dispatchToStore, store, ... } from "./reactTestExtensions"; -
将所有对
render的调用替换为renderWithStore。如果你正在进行搜索和替换操作,请注意:单词render出现在一些测试描述中,你应该保持它们不变。 -
让我们重写一个单独的测试:描述为
sends HTTP request to POST /customers when submitting data的那个测试。将该测试更改为以下内容:it("dispatches ADD_CUSTOMER_REQUEST when submitting data", async () => { renderWithStore( <CustomerForm {...validCustomer} /> ); await clickAndWait(submitButton()); return expectRedux(store) .toDispatchAnAction() .matching({ type: 'ADD_CUSTOMER_REQUEST', customer: validCustomer }); }); -
为了使这个通过,我们将使用并排实现来确保我们的其他测试继续通过。在
handleSubmit中添加以下代码片段中突出显示的行。这调用了一个我们很快就会定义的新addCustomerRequest属性:const handleSubmit = async (event) => { event.preventDefault(); const validationResult = validateMany( validators, customer ); if (!anyErrors(validationResult)) { await doSave(); dispatch(addCustomerRequest(customer)); } else { setValidationErrors(validationResult); } }; -
这使用了
useDispatch钩子。现在按照以下方式导入它:import { useDispatch } from "react-redux"; -
然后,将此行添加到
CustomerForm组件的顶部:const dispatch = useDispatch(); -
为了使测试通过,剩下的只是
addCustomerRequest的定义,你可以在import语句和CustomerForm组件定义之间添加它,如下所示:const addCustomerRequest = (customer) => ({ type: "ADD_CUSTOMER_REQUEST", customer, });
到目前为止,你的组件现在是 Redux 感知的,并且正在向 Redux 派遣正确的动作。剩余的工作是修改组件以处理来自 Redux 的验证错误,而不是组件状态。
在组件中使用存储状态
现在,是时候引入useSelector钩子来从存储中提取状态了。我们将从ADD_CUSTOMER_FAILED通用错误动作开始。回想一下,当 reducer 接收到这个动作时,它会将error存储状态值更新为true。按照以下步骤操作:
-
找到名为
renders error message when fetch call fails的测试,并用下面的实现替换它。它模拟了一个ADD_CUSTOMER_FAILED动作,以确保所有 Redux 连接都是正确的。别忘了从测试函数中移除async关键字:it("renders error message when error prop is true", () => { renderWithStore( <CustomerForm {...validCustomer} /> ); dispatchToStore({ type: "ADD_CUSTOMER_FAILED" }); expect(element("[role=alert]")).toContainText( "error occurred" ); }); -
在
src/CustomerForm.js的顶部添加一个useSelector钩子的import语句,如下所示:import { useDispatch, useSelector } from "react-redux"; -
在
CustomerForm组件的顶部调用useSelector钩子,如下面的代码片段所示。它从 Redux 存储的customer部分提取出error状态值:const { error, } = useSelector(({ customer }) => customer); -
删除任何调用
setError的行。在doSave中有两个出现。 -
现在,你可以删除在
CustomerForm组件顶部使用useState钩子定义的error/setError变量对。由于error被声明了两次,你的测试将无法运行,直到你这样做。在这个阶段,你的测试应该通过。 -
下一个测试,
clears error message when fetch call succeeds,可以被删除。根据现状,reducer 实际上并没有做这件事;完成它是练习部分的一个练习。 -
找到
does not submit the form when there are validation errors测试,并按以下方式更新它。它应该已经通过:it("does not submit the form when there are validation errors", async () => { renderWithStore( <CustomerForm original={blankCustomer} /> ); await clickAndWait(submitButton()); return expectRedux(store) .toNotDispatchAnAction(100) .ofType("ADD_CUSTOMER_REQUEST"); });
toNotDispatchAnAction匹配器
这个匹配器应该始终与超时一起使用,例如在这种情况下使用 100 毫秒。这是因为,在异步环境中,事件可能只是发生得较慢,而不是根本不发生。
-
找到下一个测试,
renders field validation errors from server。用以下代码替换它,记得从函数定义中删除async关键字:it("renders field validation errors from server", () => { const errors = { phoneNumber: "Phone number already exists in the system" }; renderWithStore( <CustomerForm {...validCustomer} /> ); dispatchToStore({ type: "ADD_CUSTOMER_VALIDATION_FAILED", validationErrors: errors }); expect( errorFor(phoneNumber) ).toContainText(errors.phoneNumber); }); -
要使这个通过,我们需要从 Redux 客户存储中提取
validationErrors。这里有一些复杂性:组件已经有一个validationErrors状态变量,它涵盖了服务器和客户端验证错误。我们无法完全替换它,因为它除了处理服务器错误外,还处理客户端错误。
因此,让我们将服务器返回的属性重命名,如下所示:
const {
error,
validationErrors: serverValidationErrors,
} = useSelector(({ customer }) => customer);
设计问题
这突显了我们原始代码中的设计问题。validationErrors状态变量有两个用途,它们被混淆了。我们在这里的更改将分离这些用途。
-
我们还没有完成这个测试。更新
renderError函数以渲染validationErrors(客户端验证)和serverValidationErrors(服务器端验证)的错误,如下所示:const renderError = fieldName => { const allValidationErrors = { ...validationErrors, ...serverValidationErrors }; return ( <span id={`${fieldname}error`} role="alert"> {hasError(allValidationErrors, fieldName) ? allValidationErrors[fieldname] : ""} </span> ); }; -
我们接下来需要查看的测试是提交指示器的测试。我们将更新这些测试以响应存储操作而不是表单提交。这是第一个:
it("displays indicator when form is submitting", () => { renderWithStore( <CustomerForm {...validCustomer} /> ); dispatchToStore({ type: "ADD_CUSTOMER_SUBMITTING" }); expect( element(".submittingIndicator") ).not.toBeNull(); }); -
要使这个通过,需要在
useSelector调用中添加status,如下所示:const { error, status, validationErrors: serverValidationErrors, } = useSelector(({ customer }) => customer); -
删除在此组件内部任何调用
setSubmitting的地方。 -
删除
submitting状态变量,并用以下代码行替换它。现在测试应该通过了:const submitting = status === "SUBMITTING"; -
然后,更新名为
hides indicator when form has submitted的测试,如下所示。这个测试不需要对生产代码进行任何更改:it("hides indicator when form has submitted", () => { renderWithStore( <CustomerForm {...validCustomer} /> ); dispatchToStore({ type: "ADD_CUSTOMER_SUCCESSFUL" }); expect(element(".submittingIndicator")).toBeNull(); }); -
最后,找到
disable the submit button when submitting测试,并以与步骤 12相同的方式进行修改。
测试更改到此结束,doSave几乎完全冗余。然而,对onSave的调用仍然需要迁移到 Redux saga 中,我们将在下一节中这样做。
在 Redux saga 中导航路由历史
回想一下,是App组件渲染CustomerForm,并且App通过将一个函数传递给CustomerForm的onSave属性来导致页面导航。当客户信息已提交时,用户将被移动到/addAppointment路由。
但是,现在表单提交发生在 Redux saga 中,我们如何调用onSave属性?答案是,我们不能。相反,我们可以将页面导航移动到 saga 本身,并完全删除onSave属性。
要做到这一点,我们必须更新src/index.js以使用HistoryRouter而不是BrowserRouter。这允许你传递自己的历史单例对象,然后你可以显式地构造它并通过 saga 访问它。按照以下步骤进行:
-
创建一个名为
src/history.js的新文件,并将以下内容添加到其中。这与我们在test/reactTestExtensions.js中已经做过的非常相似:import { createBrowserHistory } from "history"; export const appHistory = createBrowserHistory(); -
更新
src/index.js,如下所示:import React from "react"; import ReactDOM from "react-dom/client"; import { Provider } from "react-redux"; import { unstable_HistoryRouter as HistoryRouter } from "react-router-dom"; import { appHistory } from "./history"; import { configureStore } from "./store"; import { App } from "./App"; ReactDOM.createRoot( document.getElementById("root") ).render( <Provider store={configureStore()}> <HistoryRouter history={appHistory}> <App /> </HistoryRouter> </Provider> ); -
现在,我们可以在 saga 中使用
appHistory。打开test/sagas/customer.js,并将以下import语句添加到文件顶部:import { appHistory } from "../../src/history"; -
然后,添加以下两个测试以定义导航应该如何发生:
it("navigates to /addAppointment on success", () => { store.dispatch(addCustomerRequest()); expect(appHistory.location.pathname).toEqual( "/addAppointment" ); }); it("includes the customer id in the query string when navigating to /addAppointment", () => { store.dispatch(addCustomerRequest()); expect( appHistory.location.search ).toEqual("?customer=123"); }); -
要使这些测试通过,首先打开
src/sagas/customer.js并添加以下import语句:import { appHistory } from "../history"; -
然后,更新
addCustomer生成器函数,在成功添加客户后进行导航,如下所示:export function* addCustomer({ customer }) { ... yield put({ type: "ADD_CUSTOMER_SUCCESSFUL", customer: customerWithId, }); appHistory.push( `/addAppointment?customer=${customerWithId.id}` ); } -
现在,剩下要做的就是从
App和CustomerForm中删除现有的onSave配管。打开test/App.test.js并删除以下三个测试:-
使用正确的配置调用 fetch -
在 CustomerForm 提交后导航到/addAppointment -
在 CustomerForm 提交后将保存的客户传递给 AppointmentFormLoader
-
-
您还可以删除在标记为
when POST 请求返回错误的嵌套describe块中设置global.fetch的beforeEach块。 -
在
src/App.js中,删除transitionToAddAppointment的定义,并将/addCustomer路由的onSave属性更改为无,如下代码片段所示。此时,您的App测试应该已经通过:<Route path="/addCustomer" element={<CustomerForm original={blankCustomer} />} /> -
现在,我们可以从
CustomerForm中删除onSave属性。首先,从CustomerForm测试套件中删除以下不再必要的测试:-
当表单提交时通知 onSave -
如果 POST 请求返回错误则不通知 onSave
-
-
从
CustomerForm组件中删除onSave属性。 -
最后,从
handleSubmit中移除对doSave的调用。此函数不再等待任何内容,因此您可以从函数定义中安全地移除async。此时,所有您的测试应该都通过了。
您现在已经看到了如何将 Redux 存储集成到您的 React 组件中,以及如何在 Redux 桥接中控制 React Router 导航。
如果一切顺利,您的应用程序现在应该已经运行,由 Redux 管理工作流程。
摘要
这是对 Redux 及如何使用 TDD 重构您的应用程序进行的一次快速浏览。
如本章引言中所警告的,Redux 是一个复杂的库,它将大量的额外配管引入到您的应用程序中。幸运的是,测试方法很简单。
在下一章中,我们将添加另一个库:Relay,GraphQL 客户端。
练习
- 修改客户还原器以确保在
ADD_CUSTOMER_SUCCESSFUL动作发生时将error重置为false。
进一步阅读
更多信息,请参阅以下资源:
- MDN 关于生成器函数的文档:
developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/function*
expect-redux包的主页:
github.com/rradczewski/expect-redux
第十三章:测试驱动 GraphQL
GraphQL 为获取数据提供了 HTTP 请求的替代方案。它为数据请求提供了一系列额外的功能。
与 Redux 类似,GraphQL 系统可能看起来很复杂,但 TDD 有助于提供理解和学习的途径。
在本章中,我们将使用 CustomerHistory 组件来显示单个客户的详细信息及其预约历史。
这是一个基础的 GraphQL 实现,展示了测试驱动技术的核心。如果您使用的是其他 GraphQL 库而不是 Relay,本章中我们将探讨的技术也适用。
这是新 CustomerHistory 组件的外观:
图 13.1 – 新的 CustomerHistory 组件
本章涵盖了以下主题:
-
在开始之前编译模式
-
测试驱动 Relay 环境
-
从组件内部获取 GraphQL 数据
到本章结束时,您将探索测试驱动方法在 GraphQL 中的应用。
技术要求
本章的代码文件可以在此处找到:
在开始之前编译模式
本章的代码示例已经包含了一些新增内容:
-
react-relay、relay-compiler和babel-plugin-relay包。 -
Babel 配置以确保您的构建过程理解新的 GraphQL 语法。
-
在
relay.config.json文件中的 Relay 配置。主要的配置项是模式的存储位置。 -
文件
src/schema.graphql中的 GraphQL 模式。 -
一个位于
POST/graphql的服务器端点,用于处理传入的 GraphQL 请求。
本书不涉及这些内容的每个细节,但您在开始之前需要编译模式,可以通过输入以下命令来完成:
npx relay-compiler
npm run build 命令也已修改,以便在您忘记的情况下为您运行此命令。一旦所有内容都编译完成,您就可以开始编写测试了。
测试 Relay 环境
有几种不同的方法可以将 Relay 集成到 React 应用程序中。本书中我们将使用 fetchQuery 函数,该函数与我们已经用于标准 HTTP 请求的 global.fetch 函数类似。
然而,Relay 的 fetchQuery 函数的设置比 global.fetch 复杂得多。
fetchQuery 函数的一个参数是 环境,在本节中,我们将了解这是什么以及如何构建它。
为什么我们需要构建一个环境?
继电器环境是一个扩展点,可以添加各种功能。数据缓存就是一个例子。如果你对此感兴趣,请查看本章末尾的进一步阅读部分。
我们将构建一个名为buildEnvironment的函数,然后是另一个名为getEnvironment的函数,它提供这个环境的单例实例,这样初始化只需要进行一次。这两个函数都返回一个类型为Environment的对象。
Environment构造函数所需的参数之一是一个名为performFetch的函数。不出所料,这个函数实际上是获取数据的部分——在我们的例子中,是从POST /graphql服务器端点获取数据。
在一个单独的测试中,我们将检查performFetch是否传递给了新的Environment对象。我们需要将performFetch视为其自身的单元,因为我们不会测试结果环境的操作行为,而只是测试其构建。
构建 performFetch 函数
让我们首先创建自己的performFetch函数:
-
创建一个新文件,
test/relayEnvironment.test.js,并添加以下设置。这以通常的方式设置我们的global.fetch间谍。这里有两个新的常量,text和variables,我们将很快使用:import { fetchResponseOk, fetchResponseError } from "./builders/fetch"; import { performFetch } from "../src/relayEnvironment"; describe("performFetch", () => { let response = { data: { id: 123 } }; const text = "test"; const variables = { a: 123 }; beforeEach(() => { jest .spyOn(global, "fetch") .mockResolvedValue(fetchResponseOk(response)); }); }); -
然后,添加第一个测试,检查我们是否发出了适当的 HTTP 请求。
performFetch函数调用包含两个参数,它们包含text(封装在对象中)和variables。这模仿了继电器环境将如何为每个请求调用performFetch函数:it("sends HTTP request to POST /graphql", () => { performFetch({ text }, variables); expect(global.fetch).toBeCalledWith( "/graphql", expect.objectContaining({ method: "POST", }) ); }); -
创建一个新文件,
src/relayEnvironment.js,并使用以下代码使测试通过:export const performFetch = (operation, variables) => global .fetch("/graphql", { method: "POST", }); -
添加我们 HTTP 请求舞蹈的第二个测试,确保我们传递了正确的请求配置:
it("calls fetch with the correct configuration", () => { performFetch({ text }, variables); expect(global.fetch).toBeCalledWith( "/graphql", expect.objectContaining({ credentials: "same-origin", headers: { "Content-Type": "application/json" }, }) ); }); -
通过添加这里突出显示的两行代码来使它通过:
export const performFetch = (operation, variables) => global .fetch("/graphql", { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, }); -
然后,添加我们 HTTP 请求舞蹈的第三个和最后一个测试。这个测试检查我们是否传递了正确的请求数据——所需的
text查询和包含在内的variables参数:it("calls fetch with query and variables as request body", async () => { performFetch({ text }, variables); expect(global.fetch).toBeCalledWith( "/graphql", expect.objectContaining({ body: JSON.stringify({ query: text, variables, }), }) ); }); -
通过定义
fetch请求的body属性来使它通过,如下所示:export const performFetch = (operation, variables) => global .fetch("/graphql", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query: operation.text, variables }) });
理解操作、文本和变量
operation参数的text属性是定义查询的静态数据,而variables参数将是与这个特定请求相关的部分。
本章中我们编写的测试并不包括检查这个继电器管道代码的行为。在编写这种不涉及行为的单元测试时,重要的是要注意,将需要某种类型的端到端测试。这将确保你的单元测试具有正确的规范。
-
下一个测试检查我们从函数返回正确的数据。继电器期望我们的
performFetch函数返回一个承诺,该承诺将解决或拒绝。在这种情况下,我们将其解决为fetch响应:it("returns the request data", async () => { const result = await performFetch( { text }, variables ); expect(result).toEqual(response); }); -
使其通过:
export const performFetch = (operation, variables) => global .fetch("/graphql", ...) .then(result => result.json()); -
现在,我们需要处理错误情况。如果发生了 HTTP 错误,我们需要让 promise 拒绝。我们使用我们之前没有见过的
expect函数的新形式;它接受一个 promise 并期望它拒绝:it("rejects when the request fails", () => { global.fetch.mockResolvedValue( fetchResponseError(500) ); return expect( performFetch({ text }, variables) ).rejects.toEqual(new Error(500)); }); -
在我们的生产代码中,我们将测试 fetch 响应的
ok属性是否为false,如果是,则拒绝 promise。添加以下函数:const verifyStatusOk = result => { if (!result.ok) { return Promise.reject(new Error(500)); } else { return result; } }; -
在你的 promise 链中调用该函数。之后,我们的
performFetch函数就完成了:export const performFetch = (operation, variables) => global .fetch("/graphql", ...) .then(verifyStatusOk) .then(result => result.json());
现在,你已经学会了如何指定和测试 Environment 构造函数所需的 performFetch 函数。现在,我们准备进行这个构造。
测试 Environment 对象的构造
我们将构建一个名为 buildEnvironment 的函数,它接受构建 Environment 对象所需的所有各种部分。之所以有这么多部分,是因为它们都是扩展点,使得配置 Relay 连接成为可能。
这些部分是我们的 performFetch 函数和一些直接来自 relay-runtime 包的 Relay 类型。我们将使用 jest.mock 一次性模拟所有这些。
让我们开始吧:
-
在相同的测试文件
test/relayEnvironment.test.js中,更新你的导入以包含新的函数:import { performFetch, buildEnvironment } from "../src/relayEnvironment"; -
现在,是时候从
relay-runtime包中导入所有我们需要的相关部分并模拟它们了。在文件顶部添加以下内容:import { Environment, Network, Store, RecordSource } from "relay-runtime"; jest.mock("relay-runtime"); -
对于我们的第一个测试,我们需要测试
Environment构造函数是否被调用:describe("buildEnvironment", () => { const environment = { a: 123 }; beforeEach(() => { Environment.mockImplementation(() => environment); }); it("returns environment", () => { expect(buildEnvironment()).toEqual(environment); }); }); -
首先,在
src/relayEnvironment.js的生产代码中添加所有导入:import { Environment, Network, RecordSource, Store } from "relay-runtime"; -
通过在文件底部添加以下代码来使测试通过:
export const buildEnvironment = () => new Environment(); -
第二个测试确保我们向
Environment函数传递了正确的参数。它的第一个参数是调用Network.create的结果,第二个参数是构造Store对象的结果。测试需要模拟这些并检查返回值:describe("buildEnvironment", () => { const environment = { a: 123 }; const network = { b: 234 }; const store = { c: 345 }; beforeEach(() => { Environment.mockImplementation(() => environment); Network.create.mockReturnValue(network); Store.mockImplementation(() => store); }); it("returns environment", () => { expect(buildEnvironment()).toEqual(environment); }); it("calls Environment with network and store", () => { expect(Environment).toBeCalledWith({ network, store }); }); });
模拟构造函数
注意我们模拟构造函数和函数调用的差异。为了模拟一个新的 Store 和一个新的 Environment,我们需要使用 mockImplementation(fn)。为了模拟 Network.create,我们需要使用 mockReturnValue(returnValue)。
-
通过更新函数以将这些参数传递给
Environment构造函数来使测试通过:export const buildEnvironment = () => new Environment({ network: Network.create(), store: new Store() }); -
接下来,我们需要确保
Network.create获取到我们的performFetch函数的引用:it("calls Network.create with performFetch", () => { expect(Network.create).toBeCalledWith(performFetch); }); -
通过将
performFetch传递给Network.create函数来实现这个通过:export const buildEnvironment = () => new Environment({ network: Network.create(performFetch), store: new Store() }); -
Store构造函数需要一个RecordSource对象。在你的测试设置中添加一个新的模拟实现RecordSource:describe("buildEnvironment", () => { ... const recordSource = { d: 456 }; beforeEach(() => { ... RecordSource.mockImplementation( () => recordSource ); }); ... }); -
添加以下测试以指定我们想要的行为:
it("calls Store with RecordSource", () => { expect(Store).toBeCalledWith(recordSource); }); -
通过构造一个新的
RecordSource对象来实现这个通过:export const buildEnvironment = () => new Environment({ network: Network.create(performFetch), store: new Store(new RecordSource()) });
就这样,buildEnvironment 就完成了!在这个阶段,你将拥有一个有效的 Environment 对象。
测试 Environment 单例实例
因为创建 Environment 需要大量的配置工作,所以通常我们会创建一次,然后在整个应用程序中使用这个值。
使用 RelayEnvironmentProvider 的替代方法
使用这里显示的单例实例的替代方法之一是使用 React Context。Relay 提供的 RelayEnvironmentProvider 组件可以帮助你做到这一点。有关更多信息,请参阅本章末尾的 进一步阅读 部分。
让我们构建 getEnvironment 函数:
-
在
test/relayEnvironment.test.js的顶部导入新的函数:import { performFetch, buildEnvironment, getEnvironment } from "../src/relayEnvironment"; -
在文件的底部,添加一个包含一个测试的第三个
describe块,针对这个函数:describe("getEnvironment", () => { it("constructs the object only once", () => { getEnvironment(); getEnvironment(); expect(Environment.mock.calls.length).toEqual(1); }); }); -
在
src/relayEnvironment.js中,通过引入一个顶层变量来存储getEnvironment的结果(如果尚未调用)来实现这一点:let environment = null; export const getEnvironment = () => environment || (environment = buildEnvironment());
环境模板代码就到这里。我们现在有一个闪亮的 getEnvironment 函数,我们可以在我们的 React 组件中使用它。
在下一节中,我们将开始构建 CustomerHistory 组件。
在组件内部获取 GraphQL 数据
现在我们有了 Relay 环境,我们可以开始构建我们的功能。回想一下介绍中提到的,我们正在构建一个新的 CustomerHistory 组件,用于显示客户详情和客户的预约列表。返回此信息的 GraphQL 查询已经存在于我们的服务器中,所以我们只需要以正确的方式调用它。查询看起来像这样:
customer(id: $id) {
id
firstName
lastName
phoneNumber
appointments {
startsAt
stylist
service
notes
}
}
这表示我们为指定的客户 ID(由 $id 参数指定)获取一个客户记录,以及他们的预约列表。
当组件挂载时,我们的组件将执行此查询。我们将直接测试 fetchQuery 的调用:
-
创建一个新的文件,
test/CustomerHistory.test.js,并添加以下设置。我们将把这个设置分成几个部分,因为它很长!首先是我们导入,以及再次调用模拟relay-runtime,这样我们就可以模拟fetchQuery:import React from "react"; import { act } from "react-dom/test-utils"; import { initializeReactContainer, render, renderAndWait, container, element, elements, textOf, } from "./reactTestExtensions"; import { fetchQuery } from "relay-runtime"; import { CustomerHistory, query } from "../src/CustomerHistory"; import { getEnvironment } from "../src/relayEnvironment"; jest.mock("relay-runtime"); jest.mock("../src/relayEnvironment"); -
现在,让我们定义一些示例数据:
const date = new Date("February 16, 2019"); const appointments = [ { startsAt: date.setHours(9, 0, 0, 0), stylist: "Jo", service: "Cut", notes: "Note one" }, { startsAt: date.setHours(10, 0, 0, 0), stylist: "Stevie", service: "Cut & color", notes: "Note two" } ]; const customer = { firstName: "Ashley", lastName: "Jones", phoneNumber: "123", appointments }; -
接下来,让我们确保
beforeEach正确设置。这个占位符使用特殊的sendCustomer模拟,来模仿fetchQuery请求的返回值:describe("CustomerHistory", () => { let unsubscribeSpy = jest.fn(); const sendCustomer = ({ next }) => { act(() => next({ customer })); return { unsubscribe: unsubscribeSpy }; }; beforeEach(() => { initializeReactContainer(); fetchQuery.mockReturnValue( { subscribe: sendCustomer } ); }); });
fetchQuery 的返回值
这个函数有一个相对复杂的用法模式。对 fetchQuery 的调用返回一个具有 subscribe 和 unsubscribe 函数属性的对象。我们使用具有 next 回调属性的对象调用 subscribe。该回调由 Relay 的 fetchQuery 在查询返回结果集时调用。我们可以使用该回调来设置组件状态。最后,unsubscribe 函数从 useEffect 块返回,以便在组件卸载或相关属性更改时调用。
-
最后,添加测试,检查我们是否以预期的方式调用
fetchQuery:it("calls fetchQuery", async () => { await renderAndWait(<CustomerHistory id={123} />); expect(fetchQuery).toBeCalledWith( getEnvironment(), query, { id: 123 } ); }); -
让我们确保这一点。创建一个新的文件,
src/CustomerHistory.js,并从导入和导出的query定义开始:import React, { useEffect } from "react"; import { fetchQuery, graphql } from "relay-runtime"; import { getEnvironment } from "./relayEnvironment"; export const query = graphql` query CustomerHistoryQuery($id: ID!) { customer(id: $id) { id firstName lastName phoneNumber appointments { startsAt stylist service notes } } } `; -
添加该组件,以及一个
useEffect钩子:export const CustomerHistory = ({ id }) => { useEffect(() => { fetchQuery(getEnvironment(), query, { id }); }, [id]); return null; }; -
如果你现在运行测试,你可能会看到错误,如下所示:
Cannot find module './__generated__/CustomerHistoryQuery.graphql' from 'src/CustomerHistory.js'
为了修复这个问题,运行以下命令来编译你的 GraphQL 查询:
npx relay-compiler
-
接下来,我们可以添加一个测试来显示当我们提取一些数据时会发生什么:
it("unsubscribes when id changes", async () => { await renderAndWait(<CustomerHistory id={123} />); await renderAndWait(<CustomerHistory id={234} />); expect(unsubscribeSpy).toBeCalled(); }); -
为了使测试通过,更新
useEffect块以返回unsubscribe函数属性:useEffect(() => { const subscription = fetchQuery( getEnvironment(), query, { id } ); return subscription.unsubscribe; }, [id]); -
然后,更新你的组件以渲染这些数据,包括客户数据:
it("renders the first name and last name together in a h2", async () => { await renderAndWait(<CustomerHistory id={123} />); await new Promise(setTimeout); expect(element("h2")).toContainText("Ashley Jones"); }); -
然后,更新你的组件以包括一个新的状态变量
customer。这是通过在我们的下一个回调定义中调用setCustomer来设置的:export const CustomerHistory = ({ id }) => { const [customer, setCustomer] = useState(null); useEffect(() => { const subscription = fetchQuery( getEnvironment(), query, { id } ).subscribe({ next: ({ customer }) => setCustomer(customer), }); return subscription.unsubscribe; }, [id]); -
通过扩展你的 JSX 来渲染客户数据,使测试通过:
const { firstName, lastName } = customer; return ( <> <h2> {firstName} {lastName} </h2> </> ); -
现在,添加一个测试来渲染客户的电话号码:
it("renders the phone number", async () => { await renderAndWait(<CustomerHistory id={123} />); expect(document.body).toContainText("123"); }); -
通过以下更改使测试通过:
const { firstName, lastName, phoneNumber } = customer; return ( <> <h2> {firstName} {lastName} </h2> <p>{phoneNumber}</p> </> ); -
现在,让我们开始渲染预约信息:
it("renders a Booked appointments heading", async () => { await renderAndWait(<CustomerHistory id={123} />); expect(element("h3")).not.toBeNull(); expect(element("h3")).toContainText( "Booked appointments" ); }); -
这很容易修复;添加一个
h3元素,如下所示:const { firstName, lastName, phoneNumber } = customer; return ( <> <h2> {firstName} {lastName} </h2> <p>{phoneNumber}</p> <h3>Booked appointments</h3> </> ); -
接下来,我们将为每个可用的预约渲染一个表格:
it("renders a table with four column headings", async () => { await renderAndWait(<CustomerHistory id={123} />); const headings = elements( "table > thead > tr > th" ); expect(textOf(headings)).toEqual([ "When", "Stylist", "Service", "Notes", ]); }); -
添加以下表格:
const { firstName, lastName, phoneNumber } = customer; return ( <> <h2> {firstName} {lastName} </h2> <p>{phoneNumber}</p> <h3>Booked appointments</h3> <table> <thead> <tr> <th>When</th> <th>Stylist</th> <th>Service</th> <th>Notes</th> </tr> </thead> </table> </> ); -
对于下一组测试,我们将使用一个
columnValues辅助函数,它将找到一个渲染的表格元素并提取列中的所有值。我们可以使用这个来测试我们的代码显示的是一系列预约的数据,而不仅仅是单个数据:const columnValues = (columnNumber) => elements("tbody > tr").map( (tr) => tr.childNodes[columnNumber] ); it("renders the start time of each appointment in the correct format", async () => { await renderAndWait(<CustomerHistory id={123} />); expect(textOf(columnValues(0))).toEqual([ "Sat Feb 16 2019 09:00", "Sat Feb 16 2019 10:00", ]); }); -
在
thead下方添加一个新的tbody元素。这引用了一个我们还没有构建的新AppointmentRow组件,但我们将在这个下一步中完成它:<table> <thead> ... </thead> <tbody> {customer.appointments.map((appointment, i) => ( <AppointmentRow appointment={appointment} key={i} /> ))} </tbody> </table> -
现在,让我们定义
AppointmentRow。在CustomerHistory定义之上添加此代码。之后,你的测试应该通过:const toTimeString = (startsAt) => new Date(Number(startsAt)) .toString() .substring(0, 21); const AppointmentRow = ({ appointment }) => ( <tr> <td>{toTimeString(appointment.startsAt)}</td> </tr> ); -
让我们添加其他列,从样式列开始:
it("renders the stylist", async () => { await renderAndWait(<CustomerHistory id={123} />); expect(textOf(columnValues(1))).toEqual([ "Jo", "Stevie" ]); }); -
将它作为
AppointmentRow的下一个列添加:const AppointmentRow = ({ appointment }) => ( <tr> <td>{toTimeString(appointment.startsAt)}</td> <td>{appointment.stylist}</td> </tr> ); -
接下来是
service字段:it("renders the service", async () => { await renderAndWait(<CustomerHistory id={123} />); expect(textOf(columnValues(2))).toEqual([ "Cut", "Cut & color", ]); }); -
再次,这仅仅涉及在
AppointmentRow中添加一个额外的td元素:const AppointmentRow = ({ appointment }) => ( <tr> <td>{toTimeString(appointment.startsAt)}</td> <td>{appointment.stylist}</td> <td>{appointment.service}</td> </tr> ); -
最后,为了渲染信息,我们还将显示
notes字段。it("renders notes", async () => { await renderAndWait(<CustomerHistory id={123} />); expect(textOf(columnValues(3))).toEqual([ "Note one", "Note two", ]); }); -
完成如这里所示的
AppointmentRow组件:const AppointmentRow = ({ appointment }) => ( <tr> <td>{toTimeString(appointment.startsAt)}</td> <td>{appointment.stylist}</td> <td>{appointment.service}</td> <td>{appointment.notes}</td> </tr> ); -
我们几乎完成了。让我们在刚刚完成的测试下方显示一个
describe块。它使用一个不执行任何操作的noSend模拟;没有调用next。这可以用来模拟数据仍在加载的情况:describe("submitting", () => { const noSend = () => unsubscribeSpy; beforeEach(() => { fetchQuery.mockReturnValue({ subscribe: noSend }); }); it("displays a loading message", async () => { await renderAndWait(<CustomerHistory id={123} />); expect(element("[role=alert]")).toContainText( "Loading" ); }); }); -
为了使测试通过,在 JSX 之前引入一个条件:
export const CustomerHistory = ({ id }) => { const [customer, setCustomer] = useState(null); useEffect(() => { ... }, [id]); if (!customer) { return <p role="alert">Loading</p>; } ... }; -
最后,让我们处理在获取数据时出现错误的情况。这使用了另一个模拟的
errorSend,它调用错误回调。它就像next回调一样,可以用来设置状态,我们将在下一步中看到:describe("when there is an error fetching data", () => { const errorSend = ({ error }) => { act(() => error()); return { unsubscribe: unsubscribeSpy }; }; beforeEach(() => { fetchQuery.mockReturnValue( { subscribe: errorSend } ); }); it("displays an error message", async () => { await renderAndWait(<CustomerHistory />); expect(element("[role=alert]")).toContainText( "Sorry, an error occurred while pulling data from the server." ); }); }); -
为了使测试通过,你需要引入一个新的
status状态变量。最初,它具有loading值。当成功时,它变为loaded,当发生错误时,它变为failed。对于failed状态,我们渲染指定的错误消息:const [customer, setCustomer] = useState(null); const [status, setStatus] = useState("loading"); useEffect(() => { const subscription = fetchQuery( getEnvironment(), query, { id } ).subscribe({ next: ({ customer }) => { setCustomer(customer); setStatus("loaded"); }, error: (_) => setStatus("failed"), }) return subscription.unsubscribe; }, [id]); if (status === "loading") { return <p role="alert">Loading</p>; } if (status === "failed") { return ( <p role="alert"> Sorry, an error occurred while pulling data from the server. </p> ); } const { firstName, lastName, phoneNumber } = customer; ...
这就完成了新的 CustomerHistory 组件。你现在已经学会了如何在你的应用程序中测试驱动 Relay 的 fetchQuery 函数的使用,并且这个组件现在可以与 App 集成了。这被留作练习。
摘要
本章探讨了如何使用 Relay 测试驱动集成 GraphQL 端点。你看到了如何测试驱动构建 Relay 环境,以及如何构建使用fetchQuery API 的组件。
在第三部分,交互性中,我们将开始在一个新的代码库中工作,这将使我们能够探索涉及撤销/重做、动画和 WebSocket 操作更复杂的用例。
在第十四章, 构建 Logo 解释器中,我们将首先编写新的 Redux 中间件来处理撤销/重做行为。
练习
通过以下步骤将CustomerHistory组件集成到你的应用程序的其余部分:
-
在
/viewHistory?customer=<customer id>处添加一个新的路由,显示CustomerHistory组件,使用一个新的中间CustomerHistoryRoute组件。 -
在
CustomerSearch屏幕上的搜索操作中添加一个新的Link,标题为查看历史记录,当按下时,将导航到新路由。
进一步阅读
RelayEnvironmentProvider组件:
relay.dev/docs/api-reference/relay-environment-provider/
第三部分 – 交互性
本部分介绍了一个新的代码库,使我们能够探索更多复杂场景,在这些场景中可以应用 TDD。你将深入探究 Redux 中间件、动画和 WebSockets。目标是展示如何使用 TDD 工作流程来处理复杂任务。
本部分包括以下章节:
-
第十四章, 构建 Logo 解释器
-
第十五章, 添加动画
-
第十六章, 与 WebSockets 协作
第十四章:构建 Logo 解释器
Logo 是在 20 世纪 60 年代创建的一个编程环境。在许多十年里,它是教授孩子们如何编码的一种流行方式——我对高中时编写 Logo 程序的记忆犹新。其核心,它是一种通过命令式指令构建图形的方法。
在本书的这一部分,我们将构建一个名为Spec Logo的应用程序。起点是一个已经可以工作的解释器和基本的 UI。在接下来的章节中,我们将向这个代码库添加更多功能。
本章提供了第二次测试 Redux 的机会。它涵盖了以下主题:
-
研究 Spec Logo 用户界面
-
在 Redux 中撤销和重做用户操作
-
通过 Redux 中间件将数据保存到本地存储
-
更改键盘焦点
到本章结束时,你将学会如何测试驱动复杂的 Redux reducer 和中间件。
技术要求
本章的代码文件可以在以下位置找到:
研究 Spec Logo 用户界面
界面有两个面板:左侧面板是绘图面板,这是 Logo 脚本输出出现的地方。右侧是一个提示框,用户可以在此编辑指令:
图 14.1:Spec Logo 界面
看一下截图。你可以看到以下内容:
-
左上角的脚本名称。这是一个用户可以点击以更改当前脚本名称的文本字段。
-
显示区域,它显示脚本输出在页面左侧。你可以看到这里绘制了一个形状,这是在提示框中输入的 Logo 语句的结果。
-
屏幕中间的海龟。这是一个标记绘图命令起点的绿色三角形。海龟有一个x和y位置,起始位置为0,0,这是屏幕的中间。可见的绘图大小为 600x600,海龟可以在这个区域内移动。海龟还有一个角度,初始为零,指向正右方。
-
右下角的提示框,标记为**>**符号。这是你输入语句的地方,可以是多行的。按下Enter键将当前提示文本发送到解释器。如果它是一个完整的语句,它将被执行,并且提示框将被清除,以便输入下一个语句。
-
上方提示框中的语句历史。它列出了所有之前执行过的语句。每个语句都有一个编号,这样你可以回溯到相应的语句。
-
右上角的菜单栏,包含撤销、重做和重置按钮。我们将在本章中构建这个菜单栏。
尽管我们本章不会编写任何 Logo 代码,但花些时间在解释器上玩耍并制作自己的绘图是值得的。以下是一份您可以使用的指令列表:
值得一看的是代码库。src/parser.js 文件和 src/language 目录包含 Logo 解释器。测试目录中也有相应的测试文件。我们不会修改这些文件,但您可能对查看此功能是如何被测试的感兴趣。
在 src/reducers/script.js 中有一个单独的 Redux reducer。它的 defaultState 定义巧妙地封装了表示 Logo 程序执行所需的一切。几乎所有的 React 组件都以某种方式使用这个状态。
在本章中,我们将向该目录添加两个额外的 reducer:一个用于撤销/重做,另一个用于提示焦点。我们还将对三个 React 组件进行修改:MenuButtons、Prompt 和 ScriptName。
让我们从构建一个新的 reducer 开始,命名为 withUndoRedo。
在 Redux 中撤销和重做用户操作
在本节中,我们将在页面顶部添加撤销和重做按钮,允许用户撤销和重做他们之前运行的语句。它们的工作方式如下:
-
初始时,两个按钮都将被禁用。
-
一旦用户执行了一个语句,撤销按钮将变为可用。
-
当用户点击撤销按钮时,最后一个语句将被撤销。
-
在这一点上,重做按钮变为可用,用户可以选择重做最后一个语句。
-
可以按顺序撤销和重做多个操作。
-
如果用户在重做可用时执行新的操作,重做序列将被清除,重做按钮再次不可用。
除了添加按钮元素外,这里的工作涉及构建一个新的 reducer,名为 withUndoRedo,它将装饰脚本 reducer。这个 reducer 将返回与脚本 reducer 相同的状态,但有两个额外的属性:canUndo 和 canRedo。此外,reducer 在其中存储 past 和 future 数组,记录过去和未来的状态。这些将不会被返回给用户,只是存储,如果用户选择撤销或重做,将替换当前状态。
构建 reducer
这个 reducer 将是一个高阶函数,当与现有的 reducer 一起调用时,返回一个新的 reducer,该 reducer 返回我们期望的状态。在我们的生产代码中,我们将用以下 store 代码替换它:
combineReducers({
script: scriptReducer
})
我们将用这个装饰过的 reducer 来替换它,这个 reducer 完全相同的 reducer,并用我们将在本节中构建的 withUndoRedo reducer 包装:
combineReducers({
script: withUndoRedo(scriptReducer)
})
为了测试这个,我们需要使用一个间谍来代替脚本 reducer,我们将称之为 decoratedReducerSpy。
设置初始状态
让我们从构建 reducer 本身开始,然后再添加按钮来练习新功能:
-
创建一个名为
test/reducers/withUndoRedo.test.js的新文件,并添加以下设置和测试,该测试指定了当我们向 reducer 传递一个未定义的状态时应该发生什么。这相当于我们开始测试其他 reducer 的方式,但在这个情况下,我们将调用传递给装饰 reducer。测试将一个undefined状态传递给 reducer,这是初始化 reducer 所需机制:import { withUndoRedo } from "../../src/reducers/withUndoRedo"; describe("withUndoRedo", () => { let decoratedReducerSpy; let reducer; beforeEach(() => { decoratedReducerSpy = jest.fn(); reducer = withUndoRedo(decoratedReducerSpy); }); describe("when initializing state", () => { it("calls the decorated reducer with undefined state and an action", () => { const action = { type: "UNKNOWN" }; reducer(undefined, action); expect(decoratedReducerSpy).toBeCalledWith( undefined, action); }); }); }); -
创建一个名为
src/reducers/withUndoRedo.js的新文件,并使用以下代码使测试通过:export const withUndoRedo = (reducer) => { return (state, action) => { reducer(state, action); }; }; -
按照以下所示将下一个测试添加到
describe块中。这使用了我们在 第六章 中首次遇到的toMatchObject匹配器,探索测试替身:it("returns a value of what the inner reducer returns", () => { decoratedReducerSpy.mockReturnValue({ a: 123 }); expect(reducer(undefined)).toMatchObject( { a : 123 } ); }); -
通过添加
return关键字来使测试通过:export const withUndoRedo = (reducer) => { return (state, action) => { return reducer(state, action); }; } -
初始时,
canUndo和canRedo都应该是false,因为没有可以移动到的前一个或未来状态。让我们将这两个测试作为一对添加,仍然在同一describe块中:it("cannot undo", () => { expect(reducer(undefined)).toMatchObject({ canUndo: false }); }); it("cannot redo", () => { expect(reducer(undefined)).toMatchObject({ canRedo: false }); }); -
为了使这些测试通过,我们需要创建一个新的对象,并添加以下属性:
export const withUndoRedo = (reducer) => { return (state, action) => { return { canUndo: false, canRedo: false, ...reducer(state, action) }; }; } -
让我们继续到 reducer 的核心部分。在执行一个动作之后,我们希望能够执行
present和future常量来表示那些状态:describe("performing an action", () => { const innerAction = { type: "INNER" }; const present = { a: 123 }; const future = { b: 234 }; beforeEach(() => { decoratedReducerSpy.mockReturnValue(future); }); it("can undo after a new present has been provided", () => { const result = reducer( { canUndo: false, present }, innerAction ); expect(result.canUndo).toBeTruthy(); }); }); -
使用以下代码使测试通过。由于我们不再处理未定义的状态,这是我们需要将现有代码包裹在条件块中的时刻:
export const withUndoRedo = (reducer) => { return (state, action) => { if (state === undefined) return { canUndo: false, canRedo: false, ...reducer(state, action) }; return { canUndo: true }; }; }; -
接下来,我们确保再次调用 reducer,因为对于这个新块,它不会发生。编写以下测试:
it("forwards action to the inner reducer", () => { reducer(present, innerAction); expect(decoratedReducerSpy).toBeCalledWith( present, innerAction ); }); -
为了使测试通过,只需在
return值之前调用 reducer:if (state === undefined) ... reducer(state, action); return { canUndo: true }; -
下一个测试显示这个对象还需要返回新的状态:
it("returns the result of the inner reducer", () => { const result = reducer(present, innerAction); expect(result).toMatchObject(future); }); -
通过将 reducer 值保存在名为
newPresent的变量中,并将其作为返回对象的一部分返回来使测试通过:const newPresent = reducer(state, action); return { ...newPresent, canUndo: true }; -
脚本 reducer 持有一个名为
nextInstructionId的特殊值。我们可以使用这个值来确定脚本指令是否被处理,或者是否发生了错误。当一条语句有效时,它将被执行,nextInstructionId将递增。但是当一条语句无法被处理时,nextInstructionId保持不变。我们可以使用这个事实来避免在语句包含错误时保存历史记录。为此,修改present和future常量以包含此参数,并添加新的测试,如下所示:const present = { a: 123, nextInstructionId: 0 }; const future = { b: 234, nextInstructionId: 1 }; ... it("returns the previous state if nextInstructionId does not increment", () => { decoratedReducerSpy.mockReturnValue({ nextInstructionId: 0 }); const result = reducer(present, innerAction); expect(result).toBe(present); }); -
通过将我们的新
return块包裹在条件语句中,并在条件不满足时返回旧状态来使测试通过:const newPresent = reducer(state, action); if ( newPresent.nextInstructionId != state.nextInstructionId ) { return { ...newPresent, canUndo: true }; } return state;
这涵盖了执行任何动作的所有功能,除了撤销和重做。下一节将介绍撤销。
处理撤销动作
我们将创建一个新的 Redux 动作,类型为 UNDO,这将导致我们将当前状态推入一个新的数组 past 中:
-
对于这个测试,我们可以重用
present和innerAction属性,所以现在将它们推送到外部的describe块中。同时,定义一个新的undoActionRedux 动作。我们将在第一个测试中使用它:describe("withUndoRedo", () => { const undoAction = { type: "UNDO" }; const innerAction = { type: "INNER" }; const present = { a: 123, nextInstructionId: 0 }; const future = { b: 234, nextInstructionId: 1 }; ... }); -
添加一个新的嵌套
describe块,包含以下测试和设置。beforeEach块设置了一个场景,其中我们已经执行了一个将存储先前状态的行动。然后我们就可以在测试中撤销它:describe("undo", () => { let newState; beforeEach(() => { decoratedReducerSpy.mockReturnValue(future); newState = reducer(present, innerAction); }); it("sets present to the latest past entry", () => { const updated = reducer(newState, undoAction); expect(updated).toMatchObject(present); }); });
在beforeEach块内执行操作
注意beforeEach设置中对reducer函数的调用。这个函数是我们要测试的函数,因此可以认为它是reducer测试设置的一部分,因为所有这些测试都依赖于至少执行了一个可以撤销的操作。这样,我们可以将这个reducer调用视为断言阶段的一部分。
-
通过以下方式修改函数以使测试通过。我们使用一个
past变量来存储先前状态。如果我们收到一个UNDO操作,我们返回该值。我们还使用switch语句,因为我们稍后会添加一个REDO的情况:export const withUndoRedo = (reducer) => { let past; return (state, action) => { if (state === undefined) ... switch(action.type) { case "UNDO": return past; default: const newPresent = reducer(state, action); if ( newPresent.nextInstructionId != state.nextInstructionId ) { past = state; return { ...newPresent, canUndo: true }; } return state; } }; }; -
接下来,让我们调整它,以便我们可以撤销任意深度的操作。添加下一个测试:
it("can undo multiple levels", () => { const futureFuture = { c: 345, nextInstructionId: 3 }; decoratedReducerSpy.mockReturnValue(futureFuture); newState = reducer(newState, innerAction); const updated = reducer( reducer(newState, undoAction), undoAction ); expect(updated).toMatchObject(present); }); -
对于这一点,我们需要将
past升级为一个数组:export const withUndoRedo = (reducer) => { let past = []; return (state, action) => { if (state === undefined) ... switch(action.type) { case "UNDO": const lastEntry = past[past.length - 1]; past = past.slice(0, -1); return lastEntry; default: const newPresent = reducer(state, action); if ( newPresent.nextInstructionId != state.nextInstructionId ) { past = [ ...past, state ]; return { ...newPresent, canUndo: true }; } return state; } }; }; -
我们还需要进行一个最后的测试。我们需要检查在撤销之后,我们也可以重做:
it("sets canRedo to true after undoing", () => { const updated = reducer(newState, undoAction); expect(updated.canRedo).toBeTruthy(); }); -
为了使这个测试通过,返回一个由
lastEntry和新的canRedo属性组成的新对象:case "UNDO": const lastEntry = past[past.length - 1]; past = past.slice(0, -1); return { ...lastEntry, canRedo: true };
这就是UNDO操作的全部内容。接下来,让我们添加REDO操作。
处理重做操作
重做与撤销非常相似,只是顺序相反:
-
首先,在顶级
describe块中添加一个 Redux 操作类型REDO的新定义:describe("withUndoRedo", () => { const undoAction = { type: "UNDO" }; const redoAction = { type: "REDO" }; ... }); -
在撤销
describe块下方,添加以下重做describe块和第一个测试。注意间谍的设置;这里的调用是mockReturnValueOnce,而不是mockReturnValue。测试需要确保它从存储的redo状态中获取其值:describe("redo", () => { let newState; beforeEach(() => { decoratedReducerSpy.mockReturnValueOnce(future); newState = reducer(present, innerAction); newState = reducer(newState, undoAction); }); it("sets the present to the latest future entry", () => { const updated = reducer(newState, redoAction); expect(updated).toMatchObject(future); }); }); -
为了使这个测试通过,在你的生产代码中,声明一个
future变量,紧挨着past的声明:let past = [], future; -
在
UNDO操作中设置此值:case "UNDO": const lastEntry = past[past.length - 1]; past = past.slice(0, -1); future = state; -
现在它已经保存,我们可以处理
REDO操作。在UNDO子句和default子句之间添加以下case语句:case "UNDO": ... case "REDO": return future; default: ... -
下一个测试是针对多级重做的。这比
undo块中的相同情况稍微复杂一些——我们需要修改beforeEach块,使其回退两次。首先,从撤销测试中提取futureFuture值并将其带入外部作用域,紧挨着其他值,位于future下方:const future = { b: 234, nextInstructionId: 1 }; const futureFuture = { c: 345, nextInstructionId: 3 }; -
现在,更新
beforeEach以向前移动两步然后后退两步:beforeEach(() => { decoratedReducerSpy.mockReturnValueOnce(future); decoratedReducerSpy.mockReturnValueOnce( futureFuture ); newState = reducer(present, innerAction); newState = reducer(newState, innerAction); newState = reducer(newState, undoAction); newState = reducer(newState, undoAction); }); -
最后,添加以下测试:
it("can redo multiple levels", () => { const updated = reducer( reducer(newState, redoAction), redoAction ); expect(updated).toMatchObject(futureFuture); }); -
为了使这个测试通过,首先初始化
future变量为一个空数组:let past = [], future = []; -
更新
UNDO子句以将当前值推入其中:case "UNDO": const lastEntry = past[past.length - 1]; past = past.slice(0, -1); future = [ ...future, state ]; -
更新
REDO子句以提取我们刚刚推入的值。在此更改之后,测试应该通过:case "REDO": const nextEntry = future[future.length - 1]; future = future.slice(0, -1); return nextEntry; -
对于我们的基础实现,我们需要编写一个最后的测试,该测试检查重做操作后跟一个撤销操作能否带我们回到原始状态:
it("returns to previous state when followed by an undo", () => { const updated = reducer( reducer(newState, redoAction), undoAction ); expect(updated).toMatchObject(present); }); -
通过设置
REDO情况中的past属性来使测试通过:case "REDO": const nextEntry = future[future.length - 1]; past = [ ...past, state ]; future = future.slice(0, -1); return nextEntry; -
这完成了我们的 reducer。然而,我们的实现存在内存泄漏!当我们生成新状态时,我们从未清除
future数组。如果用户反复点击future但变得不可访问,这是由于最新状态中的canRedo为false。
为了测试这个场景,你可以模拟序列并检查你期望返回undefined。这个测试并不很好,因为我们实际上不应该在canRedo返回false时发送REDO动作,但我们的测试最终就是这样做的:
it("return undefined when attempting a do, undo, do, redo sequence", () => {
decoratedReducerSpy.mockReturnValue(future);
let newState = reducer(present, innerAction);
newState = reducer(newState, undoAction);
newState = reducer(newState, innerAction);
newState = reducer(newState, redoAction);
expect(newState).not.toBeDefined();
});
-
为了完成这个操作,只需在设置新状态时清除
future,如下所示:if ( newPresent.nextInstructionId != state.nextInstructionId ) { past = [ ...past, state ]; future = []; return { ...newPresent, canUndo: true }; } -
我们现在已经完成了 reducer。为了完成这个任务,将其连接到我们的 Redux 存储。打开
src/store.js并做出以下更改:import { withUndoRedo } from "./reducers/withUndoRedo"; export const configureStore = ( storeEnhancers = [], initialState = {} ) => { return createStore( combineReducers({ script: withUndoRedo(scriptReducer) }), initialState, compose(...storeEnhancers) ); };
您的所有测试都应该通过,并且应用程序仍然可以运行。
然而,撤销和重做功能仍然不可访问。为此,我们需要在菜单栏中添加一些按钮。
构建按钮
这个谜题的最后一部分是添加按钮来触发新的行为,通过在菜单栏中添加撤销和重做按钮:
-
打开
test/MenuButtons.test.js并在文件的底部添加以下describe块,嵌套在MenuButtonsdescribe块内部。它使用了一些已经通过renderWithStore文件和按钮定义的辅助函数:describe("undo button", () => { it("renders", () => { renderWithStore(<MenuButtons />); expect(buttonWithLabel("Undo")).not.toBeNull(); }); }); -
通过修改
src/MenuButtons.js文件中的MenuButtons实现来执行这个操作,如下所示:export const MenuButtons = () => { ... return ( <> <button>Undo</button> <button onClick={() => dispatch(reset())} disabled={!canReset} > Reset </button> </> ); }; -
添加下一个测试,该测试检查按钮最初是禁用的:
it("is disabled if there is no history", () => { renderWithStore(<MenuButtons />); expect( buttonWithLabel("Undo").hasAttribute("disabled") ).toBeTruthy(); }); -
通过添加硬编码的
disabled属性来执行这个操作,如下所示:<button disabled={true}>Undo</button> -
现在,我们添加代码,这将需要我们连接到 Redux:
it("is enabled if an action occurs", () => { renderWithStore(<MenuButtons />); dispatchToStore({ type: "SUBMIT_EDIT_LINE", text: "forward 10\n" }); expect( buttonWithLabel("Undo").hasAttribute("disabled") ).toBeFalsy(); }); -
修改
MenuButtons以从存储中提取canUndo。它已经使用script状态来处理重置按钮的行为,因此在这种情况下,我们只需要进一步解构它:export const MenuButtons = () => { const { canUndo, nextInstructionId } = useSelector(({ script }) => script); ... const canReset = nextInstructionId !== 0; return ( <> <button disabled={!canUndo}>Undo</button> <button onClick={() => dispatch(reset())} disabled={!canReset} > Reset </button> </> ); } ); -
当点击
UNDO动作时的最终测试:it("dispatches an action of UNDO when clicked", () => { renderWithStore(<MenuButtons />); dispatchToStore({ type: "SUBMIT_EDIT_LINE", text: "forward 10\n" }); click(buttonWithLabel("Undo")); return expectRedux(store) .toDispatchAnAction() .matching({ type: "UNDO" }); }); -
通过添加下面突出显示的行来完成这个操作。我们添加了新的
undo动作辅助函数,然后使用它来调用dispatch:const reset = () => ({ type: "RESET" }); const undo = () => ({ type: "UNDO" }); export const MenuButtons = () => { ... return ( <> <button onClick={() => dispatch(undo())} disabled={!canUndo} > Undo </button> ... </> ); }; -
从步骤 2到步骤 8重复
canRedo属性从脚本状态。
需要做的最后一个更改。撤销和重做功能现在已完成。
接下来,我们将从构建 Redux reducer 转移到构建 Redux 中间件。
通过 Redux 中间件将数据保存到本地存储
在本节中,我们将更新我们的应用程序,将当前状态保存到本地存储,这是一个由用户的网络浏览器管理的持久数据存储。我们将通过 Redux 中间件来实现这一点。
每当在LocalStorage API 中执行一个语句时。当用户下次打开应用程序时,这些令牌将被读取并通过解析器重新播放。
parseTokens函数
提醒一下,解析器(在src/parser.js中)有一个parseTokens函数。这是我们将在中间件内部调用的函数,在本节中,我们将构建测试来断言我们已调用此函数。
我们将为这个任务编写一个新的 Redux 中间件。该中间件将提取脚本状态中的两个部分:name 和 parsedTokens。
在我们开始之前,让我们回顾一下浏览器的 LocalStorage API:
-
window.localStorage.getItem(key)返回本地存储中一个项的值。存储的值是一个字符串,因此如果它是一个序列化对象,那么我们需要调用JSON.parse来反序列化它。如果给定键没有值,函数返回null。 -
window.localStorage.setItem(key, value)设置一个项的值。该值被序列化为字符串,因此我们需要确保在传递之前对任何对象调用JSON.stringify。
构建中间件
让我们测试驱动我们的中间件:
-
创建
src/middleware和test/middleware目录,然后打开test/middleware/localStorage.test.js文件。为了开始,定义两个间谍函数,getItemSpy和setItemSpy,它们将组成新的对象。我们必须使用Object.defineProperty来设置这些间谍函数,因为window.localStorage属性是只写的:import { save } from "../../src/middleware/localStorage"; describe("localStorage", () => { const data = { a: 123 }; let getItemSpy = jest.fn(); let setItemSpy = jest.fn(); beforeEach(() => { Object.defineProperty(window, "localStorage", { value: { getItem: getItemSpy, setItem: setItemSpy }}); }); }); -
让我们为中间件编写第一个测试。这个测试简单地断言中间件做了所有中间件都应该做的事情,即调用
next(action)。Redux 中间件函数具有复杂的语义,是返回函数的函数,但我们的测试将轻松处理这一点:describe("save middleware", () => { const name = "script name"; const parsedTokens = ["forward 10"]; const state = { script: { name, parsedTokens } }; const action = { type: "ANYTHING" }; const store = { getState: () => state }; let next; beforeEach(() => { next = jest.fn(); }); const callMiddleware = () => save(store)(next)(action); it("calls next with the action", () => { callMiddleware(); expect(next).toBeCalledWith(action); }); }); -
为了使其通过,创建
src/middleware/localStorage.js文件并添加以下定义:export const save = store => next => action => { next(action); }; -
下一个测试检查我们是否返回该值:
it("returns the result of next action", () => { next.mockReturnValue({ a : 123 }); expect(callMiddleware()).toEqual({ a: 123 }); }); -
更新
save函数以返回该值:export const save = store => next => action => { return next(action); }; -
现在,检查我们是否将字符串化的值添加到本地存储中:
it("saves the current state of the store in localStorage", () => { callMiddleware(); expect(setItemSpy).toBeCalledWith("name", name); expect(setItemSpy).toBeCalledWith( "parsedTokens", JSON.stringify(parsedTokens) ); }); -
为了使其通过,完成
save中间件的实现:export const save = store => next => action => { const result = next(action); const { script: { name, parsedTokens } } = store.getState(); localStorage.setItem("name", name); localStorage.setItem( "parsedTokens", JSON.stringify(parsedTokens) ); return result; }; -
让我们继续到
load函数,它不是中间件,但将其放在同一文件中并无害。创建一个新的describe块,包含以下测试,并确保更新import:import { load, save } from "../../src/middleware/localStorage"; ... describe("load", () => { describe("with saved data", () => { beforeEach(() => { getItemSpy.mockReturnValueOnce("script name"); getItemSpy.mockReturnValueOnce( JSON.stringify([ { a: 123 } ]) ); }); it("retrieves state from localStorage", () => { load(); expect(getItemSpy).toBeCalledWith("name"); expect(getItemSpy).toHaveBeenLastCalledWith( "parsedTokens" ); }); }); }); -
通过在
save的定义下方添加load函数来使该操作通过,在生成代码中定义一个新的函数:export const load = () => { localStorage.getItem("name"); localStorage.getItem("parsedTokens"); }; -
现在要将这些数据发送到解析器。为此,我们需要一个
parserSpy间谍函数,我们使用它来监视解析器的parseTokens函数:describe("load", () => { let parserSpy; describe("with saved data", () => { beforeEach(() => { parserSpy = jest.fn(); parser.parseTokens = parserSpy; ... }); it("calls to parsedTokens to retrieve data", () => { load(); expect(parserSpy).toBeCalledWith( [ { a: 123 } ], parser.emptyState ); }); }); }); -
添加以下生成代码以使其通过:
import * as parser from "../parser"; export const load = () => { localStorage.getItem("name"); const parsedTokens = JSON.parse( localStorage.getItem("parsedTokens") ); parser.parseTokens(parsedTokens, parser.emptyState); }; -
下一个测试确保数据以正确的格式返回:
it("returns re-parsed draw commands", () => { parserSpy.mockReturnValue({ drawCommands: [] }); expect( load().script ).toHaveProperty("drawCommands", []); }); -
通过返回一个包含解析响应的对象来使其通过:
export const load = () => { localStorage.getItem("name"); const parsedTokens = JSON.parse( localStorage.getItem("parsedTokens") ); return { script: parser.parseTokens( parsedTokens, parser.emptyState ) }; }; -
接下来,让我们将名称添加到该数据结构中:
it("returns name", () => { expect(load().script).toHaveProperty( "name", "script name" ); }); -
为了使其通过,首先,我们需要保存从本地存储返回的名称,然后将其插入到
present对象中:export const load = () => { const name = localStorage.getItem("name"); const parsedTokens = JSON.parse( localStorage.getItem("parsedTokens") ); return { script: { ...parser.parseTokens( parsedTokens, parser.initialState ), name } }; }; -
最后,我们需要处理尚未保存任何状态的情况。在这种情况下,
LocalStorageAPI 返回null,但我们希望返回undefined,这将触发 Redux 使用默认状态。将此测试添加到外部的describe块中,这样它就不会拾取额外的getItemSpy模拟值:it("returns undefined if there is no state saved", () => { getItemSpy.mockReturnValue(null); expect(load()).not.toBeDefined(); }); -
通过将
return语句包裹在if语句中来使其通过:if (parsedTokens && parsedTokens !== null) { return { ... }; } -
打开
src/store.js并修改它以包含新的中间件。我正在定义一个新的函数configureStoreWithLocalStorage,这样我们的测试就可以继续使用configureStore而不与本地存储交互:... import { save, load } from "./middleware/localStorage"; export const configureStore = ( storeEnhancers = [], initialState = {} ) => { return createStore( combineReducers({ script: withUndoRedo(scriptReducer) }), initialState, compose( ...[ applyMiddleware(save), ...storeEnhancers ] ) ); }; export const configureStoreWithLocalStorage = () => configureStore(undefined, load()); -
打开
src/index.js并将对configureStore的调用替换为对configureStoreWithLocalStorage的调用。你还需要更新import以使用这个新函数:import { configureStoreWithLocalStorage } from "./store"; ReactDOM.createRoot( document.getElementById("root") ).render( <Provider store={configureStoreWithLocalStorage()}> <App /> </Provider> );
就这样。如果你愿意的话,这是一个运行手动测试和尝试应用的好时机。打开浏览器窗口,输入几个命令,然后试试看!
如果你不知道要运行手动测试的命令,可以使用以下命令:
forward 100
right 90
to drawSquare
repeat 4 [ forward 100 right 90 ]
end
drawSquare
这些命令在解释器和显示中的大多数功能都会得到锻炼。当你在第十五章“添加动画”中进行手动测试时,它们会很有用。
你已经学会了如何测试驱动 Redux 中间件。对于本章的最后一部分,我们将编写另一个 reducer,这次是一个帮助我们操作浏览器键盘焦点的 reducer。
改变键盘焦点
我们应用程序的用户,大多数时候,会在屏幕右下角的提示框中输入。为了帮助他们,当应用启动时,我们将键盘焦点移动到提示框。当另一个元素——例如名称文本框或菜单按钮——被使用并完成其工作后,我们也应该这样做。然后,焦点应该回到提示框,准备接收下一个指令。
React 不支持设置焦点,因此我们需要在我们的组件上使用一个React ref,然后将其放入 DOM API 中。
我们将通过 Redux reducer 来实现这一点。它将有两个动作:PROMPT_FOCUS_REQUEST和PROMPT_HAS_FOCUSED。我们应用程序中的任何 React 组件都可以发出第一个动作。Prompt组件将监听它,并在它聚焦后发出第二个动作。
编写 reducer
我们将像往常一样,从 reducer 开始:
-
创建一个名为
test/reducers/environment.test.js的新文件,并添加以下describe块。这涵盖了 reducer 在接收到undefined时需要返回默认状态的基本情况:import { environmentReducer as reducer } from "../../src/reducers/environment"; describe("environmentReducer", () => { it("returns default state when existing state is undefined", () => { expect(reducer(undefined, {})).toEqual({ promptFocusRequest: false }); }); }); -
使用以下代码使测试通过,在一个名为
src/reducers/environment.js的文件中。由于我们之前已经构建过 reducer,我们知道这次的目标在哪里:const defaultState = { promptFocusRequest: false }; export const environmentReducer = ( state = defaultState, action) => { return state; }; -
添加下一个测试,该测试检查我们是否设置了
promptFocusRequest值:it("sets promptFocusRequest to true when receiving a PROMPT_FOCUS_REQUEST action", () => { expect( reducer( { promptFocusRequest: false}, { type: "PROMPT_FOCUS_REQUEST" } ) ).toEqual({ promptFocusRequest: true }); }); -
通过添加一个
switch语句使其通过,如下所示:export const environmentReducer = ( state = defaultState, action ) => { switch (action.type) { case "PROMPT_FOCUS_REQUEST": return { promptFocusRequest: true }; } return state; }; -
为这个 reducer 添加最后的测试:
it("sets promptFocusRequest to false when receiving a PROMPT_HAS_FOCUSED action", () => { expect( reducer( { promptFocusRequest: true}, { type: "PROMPT_HAS_FOCUSED" } ) ).toEqual({ promptFocusRequest: false }); }); -
最后,通过添加另一个
case语句使其通过:export const environmentReducer = (...) => { switch (action.type) { ..., case "PROMPT_HAS_FOCUSED": return { promptFocusRequest: false }; } ... } -
在我们可以在测试中使用新的 reducer 之前,我们需要将其添加到存储中。打开
src/store.js并按以下方式修改:... import { environmentReducer } from "./reducers/environment"; export const configureStore = ( storeEnhancers = [], initialState = {} ) => { return createStore( combineReducers({ script: withUndoRedo(logoReducer), environment: environmentReducer }), ... ); };
这为我们提供了一个新的 reducer,它已经连接到 Redux 存储。现在,让我们利用它。
焦点提示
让我们继续到这部分最困难的部分:聚焦实际的提示。为此,我们需要引入一个 React ref:
-
打开
test/Prompt.test.js并在Promptdescribe块底部添加以下describe块。测试使用document.activeElement属性,它是当前具有焦点的元素。它还使用renderInTableWithStore函数,它与您已经看到的renderWithStore辅助函数相同,只是组件首先被包裹在一个表格中:describe("prompt focus", () => { it("sets focus when component first renders", () => { renderInTableWithStore(<Prompt />); expect( document.activeElement ).toEqual(textArea()); }); }); -
让我们通过这个。我们使用
useRef钩子定义一个新的引用,并添加一个useEffect钩子以在组件挂载时聚焦。确保从 React 常量中提取新的常量,该常量位于文件顶部:import React, { useEffect, useRef, useState } from "react"; export const Prompt = () => { ... const inputRef = useRef(); useEffect(() => { inputRef.current.focus(); }, [inputRef]); return ( ... <textarea ref={inputRef} /> ... ); }; -
对于下一个测试,我们将向 Redux 存储发送一个动作。由于这个测试套件还没有发送动作的测试,我们需要添加所有管道。首先,将
dispatchToStore函数导入到测试套件中:import { ..., dispatchToStore, } from "./reactTestExtensions"; -
现在,我们需要一个新的辅助函数来清除焦点。因为焦点将在组件挂载时立即设置,我们需要再次取消设置,以便我们可以验证我们的焦点请求的行为。一旦我们有了这个辅助函数,我们就可以添加下一个测试:
const jsdomClearFocus = () => { const node = document.createElement("input"); document.body.appendChild(node); node.focus(); node.remove(); } it("calls focus on the underlying DOM element if promptFocusRequest is true", async () => { renderInTableWithStore(<Prompt />); jsdomClearFocus(); dispatchToStore({ type: "PROMPT_FOCUS_REQUEST" }); expect(document.activeElement).toEqual(textArea()); }); -
为了通过这个测试,首先,创建一个新的
useSelector调用来从存储中提取promptFocusRequest值:export const Prompt = () => { const nextInstructionId = ... const promptFocusRequest = useSelector( ({ environment: { promptFocusRequest } }) => promptFocusRequest ); ... }; -
然后,添加一个当
promptFocusRequest发生变化时运行的新效果。这使用引用来调用 DOM 的focus方法在 HTML 元素上:useEffect(() => { inputRef.current.focus(); }, [promptFocusRequest]); -
对于下一个测试,当焦点发生时发送一个动作:
it("dispatches an action notifying that the prompt has focused", () => { renderWithStore(<Prompt />); dispatchToStore({ type: "PROMPT_FOCUS_REQUEST" }); return expectRedux(store) .toDispatchAnAction() .matching({ type: "PROMPT_HAS_FOCUSED" }); }); -
为了通过这个测试,首先添加一个新的动作辅助函数,我们可以在
Prompt组件中调用它:const submitEditLine = ... const promptHasFocused = () => ( { type: "PROMPT_HAS_FOCUSED" } ); -
最后,在
useEffect钩子中调用promptHasFocused:useEffect(() => { inputRef.current.focus(); dispatch(promptHasFocused()); }, [promptFocusRequest]);
最后一个代码片段有一个小问题。发送的 PROMPT_HAS_FOCUSED 动作会将 promptFocusRequest 设置回 false。这会导致 useEffect 钩子再次运行,组件重新渲染。这显然不是预期的,也不是必要的。然而,由于它对用户没有可识别的影响,我们可以暂时跳过修复它。
这完成了 Prompt 组件,现在每当 promptFocusRequest 变量值发生变化时,它都会夺取焦点。
在其他组件中请求焦点
剩下的只是当需要时调用请求动作。我们将为 ScriptName 做这件事,但你也可以为菜单栏中的按钮做这件事:
-
打开
test/ScriptName.test.js,找到名为when the user hits Enter的describe块,并添加以下测试:it("dispatches a prompt focus request", () => { return expectRedux(store) .toDispatchAnAction() .matching({ type: "PROMPT_FOCUS_REQUEST" }); }); -
在
src/ScriptName.js中,修改组件以定义一个名为promptFocusRequest的动作辅助器:const submitScriptName = ... const promptFocusRequest = () => ({ type: "PROMPT_FOCUS_REQUEST", }); -
在编辑完成处理程序中调用它:
const completeEditingScriptName = () => { if (editingScriptName) { toggleEditingScriptName(); dispatch(submitScriptName(updatedScriptName)); dispatch(promptFocusRequest()); } };
就这样!如果你现在构建并运行,你会看到焦点是如何自动赋予 prompt 文本框的,如果你编辑脚本名称(通过点击它,输入一些内容,然后按 Enter),你会看到焦点返回到提示。
摘要
你现在应该对测试驱动复杂的 Redux 红 ucer 和中间件有一个很好的理解。
首先,我们通过 Redux 装饰器 reducer 添加了撤销/重做功能。然后,我们构建了 Redux 中间件,通过浏览器的LocalStorage API 保存和加载现有状态。最后,我们探讨了如何测试驱动改变浏览器的焦点。
在下一章中,我们将探讨如何测试驱动更复杂的动画。
进一步阅读
关于 Logo 编程语言的维基百科条目: