React-测试驱动开发教程-二-

105 阅读25分钟

React 测试驱动开发教程(二)

原文:Test-Driven Development with React

协议:CC BY-NC-SA 4.0

六、实现图书详细视图

对于图书列表中的每本书,我们希望将其名称显示为超链接,这样当用户单击它时,浏览器将导航到详细页面。详细页面将包含特定于每本书的内容,包括书名、封面图片、描述、评论等等。

验收测试

在我们的bookish.spec.js中,我们可以将这个需求描述为验收测试:

it('Goes to the detail page', () => {
  cy.visit('http://localhost:3000/');
  cy.get('div.book-item').contains('View Details').eq(0).click();
  cy.url().should('include', '/books/1');
});

运行测试,它会失败。

链接到详细页面

那是因为我们还没有一条/books路线,我们也没有链接。为了使测试通过,在BookList组件中添加一个超链接:

     {
       books.map(book => (<div className='book-item' key={book.id}>
         <h2 className='title'>{book.name}</h2>
+        <a href={`/books/${book.id}`}>View Details</a>
       </div>))
     }

验证详细页面上的图书标题

然后,为了确保页面在导航后显示预期的内容,我们需要在bookish.spec.js中添加一行:

  it('Goes to the detail page', () => {
     cy.visit('http://localhost:3000/');
     cy.get('div.book-item').contains('View Details').eq(0).click();
     cy.url().should('include', '/books/1');
+    cy.get('h2.book-title').contains('Refactoring');
  });

该检查页面有一个.book-title部分,它的内容是Refactoring。测试再次失败;让我们通过在应用中添加客户端路由来解决这个问题。

正如您所看到的,这里有一个页面导航:当用户点击一个button时,将能够跳转到detail page。这意味着我们需要某种机制来维护路由。

前端路由

我们需要添加react-routerreact-router-dom作为依赖项,它们为我们提供了客户端路由机制:

npm install react-router react-router-dom

index.js中,我们导入BrowserRouter并将其包裹在<App />周围。这意味着整个应用可以共享全局Router配置。

+import {BrowserRouter as Router} from 'react-router-dom'
+
-ReactDOM.render(<App />, document.getElementById('root'));
+ReactDOM.render(<Router>
+  <App />
+</Router>, document.getElementById('root'));

然后我们在App.js中定义两条路线:

+import {Route, Switch} from 'react-router-dom';
 import BookListContainer from './BookListContainer';
+import BookDetailContainer from './BookDetailContainer';

 const App = () => {
   return (
@@ -8,7 +10,10 @@ const App = () => {
       <Typography variant='h2' component='h2' data-test='heading'>
       Bookish
       </Typography>
-      <BookListContainer/>
+      <Switch>
+        <Route exact path='/' component={BookListContainer} />
+        <Route path='/books/:id' component={BookDetailContainer} />
+      </Switch>
     </div>
   );
 }

通过这些路径,当用户访问根路径/时,组件BookListContainer将被呈现。当访问/books/123时,将显示BookDetailContainer

BookDetailContainer组件

最后,我们需要创建一个新文件BookDetailContainer.js。它将与第一版BookListContainer.js非常相似,除了这本书的id将作为match.params.id通过react-router。一旦我们有了图书 id,我们就可以发送一个 HTTP 请求来加载图书的详细信息:

```js BookDetailContainer.js

import React, {useEffect, useState} from ‘react’
import axios from ‘axios’
const BookDetailContainer = ({match}) => { const [id, _] = useState(match.params.id); const [book, setBook] = useState({});
useEffect(() => { const fetchBook = async () => { const book = await axios.get(http://localhost:8080/books/${id}); setBook(book.data); };
fetchBook();
}, [id]);
return (
<h2 className='book-title'>{book.name}</h2>
) }
export default BookDetailContainer ```jsx

很好,功能测试现在通过了(图 6-1 )。

img/510354_1_En_6_Fig1_HTML.jpg

图 6-1

图书详细页的验收测试

一般化useRemoteService钩子

然而,数据获取过程可以改进。是时候让我们重构useRemoteService来适应新的需求了。因为我们已经准备好了更高级别的测试,所以我们可以自信地做出一些改变。

-export const useRemoteService = (initial) => {
+export const useRemoteService = (url, initialData) => {
   const [data, setData] = useState(initialData);
   const [loading, setLoading] = useState(false);
   const [error, setError] = useState(false);

       setLoading(true);

       try {
-        const res = await axios.get('http://localhost:8080/books');
+        const res = await axios.get(url);
         setData(res.data);
       } catch (e) {
         setError(true);

我们将硬编码的url作为参数移出,在调用位置,简单地说

const {data, loading, error} = useRemoteService('http://localhost:8080/books', []);

用新挂钩简化BookDetailContainer

对于BookDetailContainer,它可以简化为

import React from 'react'
import {useRemoteService} from './hooks';

const BookDetailContainer = ({match}) => {
  const {data} = useRemoteService(`http://localhost:8080/books/${match.params.id}`, {});

  return (<div className='detail'>
    <h2 className='book-title'>{data.name}</h2>
  </div>)
};

export default BookDetailContainer

代码现在看起来干净多了。

单元测试

在端到端测试中,我们只需确保细节页面中有一个title。如果我们在页面上添加更多的细节,比如descriptionbook cover,我们会在更低层次的测试——单元测试中检查它们。单元测试运行速度快,比端到端测试检查更多的具体细节,如果出现问题,开发人员更容易调试。

重构

提取表示组件

尽管在BookDetailContainer中只有一行来呈现细节,但是将该行提取到一个单独的组件中是一个好主意——我们称之为BookDetail:

import React from 'react';

const BookDetail = ({book}) => {
  return (<div className='detail'>
    <h2 className='book-title'>{book.name}</h2>
  </div>)
}

export default BookDetail;

BookDetailContainer那么可以简化为

const BookDetailContainer = ({match}) => {
  const {data} = useRemoteService(`http://localhost:8080/books/${match.params.id}`, {});
  return (<BookDetail book={data}/>);
};

现在让我们检查所有的测试,功能测试都通过了,但是根据您使用的 react-router 和 react-testing-library 的版本,您的单元测试可能会显示以下错误消息:

BookList › render books

Invariant failed: You should not use <Link> outside a <Router>

MemoryRouter用于测试

为了解决这个问题,我们需要通过提供一个<MemoryRouter>来稍微修改一下BookList.test.js:

 import BookList from './BookList';

+import {MemoryRouter as Router} from 'react-router-dom';
+
+const renderWithRouter = (component) => {
+  return {...render(<Router>
+      {component}
+    </Router>)}
+};
+

我们在render中添加了一个包装器。这将把您传入的任何组件包装在一个MemoryRouter中。然后我们可以在所有需要渲染Link的测试中调用renderWithRouter而不是render:

it('render books', () => {
  const props = {
    books: [
      { 'name': 'Refactoring', 'id': 1 },
      { 'name': 'Domain-driven design', 'id': 2 },
    ]
  };
  const { container } = renderWithRouter(<BookList {...props} />);
  const titles = [...container.querySelectorAll('h2')].map(x => x.innerHTML);
  expect(titles).toEqual(['Refactoring', 'Domain-driven design']);
})

图书详细信息页面

书名

现在,我们可以在文件BookDetail.test.js中快速添加单元测试,以便驱动实现:

describe('BookDetail', () => {
  it('renders title', () => {
    const props = {
      book: {
        name: 'Refactoring'
      }
    };

    const {container} = render(<BookDetail {...props} />);

    const title = container.querySelector('.book-title');
    expect(title.innerHTML).toEqual(props.book.name);
  })
});

这个测试将会通过,因为我们已经呈现了name字段。

书籍描述

让我们再添加一些字段:

it('renders description', () => {
  const props = {
    book: {
      name: 'Refactoring',
      description: "Martin Fowler's Refactoring defined core ideas and techniques " +
        "that hundreds of thousands of developers have used to improve " +
        "their software."
    }
  };

  const { container } = render(<BookDetail {...props} />);

  const description = container.querySelector('p.book-description');
  expect(description.innerHTML).toEqual(props.book.description);
})

一个简单的实现如下所示:

 const BookDetail = ({book}) => {
   return (<div className='detail'>
     <h2 className='book-title'>{book.name}</h2>
+    <p className='book-description'>{book.description}</p>
   </div>)
 }

所有测试现在都以漂亮的绿色通过了!让我们后退一步,看看我们是否能把代码库做得更好一点。我注意到的一件事是,随着我们创建更多的文件,整个项目结构有点爆炸。

文件结构

我们的文件结构非常扁平——根本没有层次结构,因为所有文件都在一个文件夹中。那是代码的味道。很难找到我们想要的东西。让我们重组。

目前,我们的文件如下所示:

src
├── App.js
├── BookDetail.jsx
├── BookDetail.test.jsx
├── BookDetailContainer.jsx
├── BookList.jsx
├── BookList.test.jsx
├── BookListContainer.jsx
├── hooks.js
└── index.js

有多种方法可以将应用分割成模块并组织它们。在尝试了各种项目的不同组合后,我发现用feature分割应用对我来说是最有意义的。

模块化

所以现在,让我们定义两个独立的文件夹:BookDetailBookList分别用于特性一和特性二。

src
├── App.js
├── BookDetail
│   ├── BookDetail.jsx
│   ├── BookDetail.test.jsx
│   └── BookDetailContainer.jsx
├── BookList
│   ├── BookList.jsx
│   ├── BookList.test.jsx
│   └── BookListContainer.jsx
├── hooks.js
└── index.js

这是很有条理的,读者很容易找到需要更改的组件。

测试数据

您可能会发现为功能测试清理所有数据有点棘手。而当你想手动检查应用在浏览器中的样子时,根本没有数据。

让我们通过为json-server引入另一个database文件来解决这个问题:

{
  "books": [
    {"name": "Refactoring", "id": 1, "description": "Martin Fowler's Refactoring defined core ideas and techniques that hundreds of thousands of developers have used to improve their software."},
    {"name": "Domain-driven design", "id": 2, "description": "Explains how to incorporate effective domain modeling into the software development process."},
    {"name": "Building Microservices", "id": 3, "description": "Author Sam Newman provides you with a firm grounding in the concepts while diving into current solutions for modeling, integrating, testing, deploying, and monitoring your own autonomous services."},
    {"name": "Acceptance Test Driven Development with React", "id": 4, "description": "This book describes how to apply the Acceptance Test Driven Development when developing a Web Application named bookish with React / Redux and other tools in react ecosystem."}
  ]
}

并将内容作为books.json保存在stub-server文件夹中。现在,更新package.json中的stub-server脚本:

json-server --watch books.json --port 8080

并运行服务器(图 6-2 ): npm run stub-server

img/510354_1_En_6_Fig2_HTML.jpg

图 6-2

用假数据运行我们的存根服务器

记住在这里也要运行端到端测试。当我们改变书单中的预期数据时,我们也需要改变测试的预期。由于服务器正在模拟所有的数据,您会注意到此时我们不需要 beforeEach 和 afterEach。

用户界面优化

我们现在已经完成了两个令人兴奋和具有挑战性的功能。不过用户界面有点平淡(图 6-3);让我们添加一些造型。

img/510354_1_En_6_Fig3_HTML.jpg

图 6-3

Bookish 的用户界面草稿

Material-UI 提供了许多基本的和更高级的 UI 组件,以及其他助手,比如一个responsive网格系统。

使用Grid系统

在我们的例子中,让我们为我们的BookList实现GridCard组件:

import React from 'react';
+ import {
+   Button,
+   Card,
+   CardActionArea,
+   CardActions,
+   CardContent,
+   Grid,
+   Typography,
+ } from '@material-ui/core';
import { Link } from 'react-router-dom';

const BookList = ({ loading, error, books }) => {
  const classes = useStyles();
  if (loading) {
    return <p>Loading...</p>;
  }

  if (error) {
    return <p>Error...</p>;
  }

-  return <div data-test='book-list'>
-    {
-      books.map(book => (<div className='book-item' key={book.id}>
-        <h2 className='title'>{book.name}</h2>
-        <Link to={`/books/${book.id}`}>View Details</Link>
-      </div>))
-    }
+  return <div data-test='book-list' className={classes.root}>
+    <Grid container spacing={3}>
+      {
+        books.map(book => (<Grid item xs={4} sm={4} key={book.id} className='book-item' >
+          <Card>
+            <CardActionArea>
+              <CardContent>
+                <Typography gutterBottom variant='h5' component='h2' className={classes.name}>
+                  {book.name}
+                </Typography>
+                <Typography variant='body2' color='textSecondary' component='p' className={classes.description}>
+                  {book.description}
+                </Typography>
+              </CardContent>
+            </CardActionArea>
+            <CardActions>
+              <Button size='small' color='primary'>
+                <Link to={`/books/${book.id}`}>View Details</Link>
+              </Button>
+            </CardActions>
+          </Card>
+        </Grid>))
+      }
+    </Grid>
   </div>;
 }

这可能看起来有点多,但这些实际上只是标记——想想适合我们应用的 HTML 标记。

为组件创建样式

为了做到这一点,我们需要使用 Material-UI 的makeStyles函数,它将使用钩子模式将样式表与函数组件链接起来(图 6-4 )。

img/510354_1_En_6_Fig4_HTML.jpg

图 6-4

带有材质的用户界面-UI

const useStyles = makeStyles(theme => ({
  root: {
    flexGrow: 1,
  },
  paper: {
    padding: theme.spacing(2),
    textAlign: 'center',
    color: theme.palette.text.secondary,
  },
  name: {
    maxHeight: 30,
    overflow: 'hidden',
    textOverflow: 'ellipsis',
  },
  description: {
    maxHeight: 40,
    overflow: 'hidden',
    textOverflow: 'ellipsis',
  }
}));

在组件的开始,我们调用useStyles来生成可以用作className的类名:

const classes = useStyles();

处理默认值

现在,我们有一个需求调整:后端服务提供的数据可能在一些字段中包含一些意外的 null 值,我们需要优雅地处理这些值。例如,不能保证description字段总是存在(它可能是空字符串或空值)。在这种情况下,我们需要使用图书名称作为描述后备。

使用undefined的失败测试

我们可以添加一个测试来描述这种情况,注意 props 对象根本不包含description字段:

it('displays the book name when no description was given', () => {
  const props = {
    book: {
      name: 'Refactoring'
    }
  }
  const { container } = render(<BookDetail {...props} />);

  const description = container.querySelector('p.book-description');
  expect(description.innerHTML).toEqual(props.book.name);
})

然后我们的测试又失败了(图 6-5 )。

img/510354_1_En_6_Fig5_HTML.jpg

图 6-5

数据不完整时测试失败

我们可以用一个条件运算符来解决这个问题:

const BookDetail = ({book}) => {
  return (<div className='detail'>
    <h2 className='book-title'>{book.name}</h2>
    <p className='book-description'>{book.description ? book.description : book.name}</p>
  </div>)
}

这里值得注意的是conditional operator。就目前而言,这很简单。但它可能会很快变得复杂。一个更好的选择是将该表达式作为一个单独的函数提取出来。例如,我们可以使用提取函数将潜在变化隔离到一个纯计算函数中。

const getDescriptionFor = (book) => {
  return book.description ? book.description : book.name;
}

const BookDetail = ({book}) => {
  return (<div className='detail'>
    <h2 className='book-title'>{book.name}</h2>
    <p className='book-description'>{getDescriptionFor(book)}</p>
  </div>)
}

这样,我们将renderingcomputing分开,这可以带来更好的可测试性和可读性。

最后一次?变化

现在让我们考虑另一个变化:如果description的长度大于 300 个字符,我们需要在 300 个字符处截断内容,并显示一个show more...链接。当用户单击该链接时,将显示完整的内容。

我们可以为这种情况添加一个新的测试:

it('Shows *more* link when description is too long', () => {
  const props = {
    book: {
      name: 'Refactoring',
      description: 'The book about how to do refactoring ....'
    }
  };

  const { container } = render(<BookDetail {...props} />);

  const link = container.querySelector('a.show-more');
  const title = container.querySelector('p.book-description');

  expect(link.innerHTML).toEqual('Show more');
  expect(title.innerHTML).toEqual('The book about how to do refactoring ....');
})

这促使我们以满足需求的方式编写或修改代码。一旦所有的测试都通过了,我们就可以进行重构:提取方法,创建新文件,移动方法或类,重命名变量或改变文件夹结构,等等。

这是一种无休止的过程。我们总有进步的空间。当我们有足够的时间时,我们可以重复这个过程,直到代码变得干净并且自文档化

摘要

在这一章中,我们已经通过应用验收测试驱动的开发方法实现了 Book Detail 特性,并学习了如何迭代地将其重构到理想状态。让我们在下一章更深入地讨论用存根技术进行测试。

七、按关键字搜索

我们的第三个特性将允许用户通过书名搜索一本书。当图书列表变得很长时,这很有用——当内容超过一个屏幕或页面时,用户可能很难找到他们要找的内容。

接收试验

如前所述,我们首先编写一个acceptance test:

  it('Searches for a title', () => {
    cy.visit('http://localhost:3000/');
    cy.get('div.book-item').should('have.length', 4);
    cy.get('[data-test="search"] input').type('design');
    cy.get('div.book-item').should('have.length', 1);
    cy.get('div.book-item').eq(0).contains('Domain-driven design');
  });

这个测试试图在search输入框中键入关键字design,并期望只有Domain-driven design会出现在图书列表中。

实现这个特性最简单的方法是通过添加一个来自material-uiTextField来修改BookListContainer:

  return (<>
    <TextField
      label='Search'
      value={term}
      data-test='search'
      onChange={(e) => setTerm(e.target.value)}
      margin='normal'
      variant='outlined'
    />
    <BookList books={data} loading={loading} error={error}/>
  );

我们需要将state引入组件——在 return 语句之前,添加下面一行,记住从react导入useState:

const [term, setTerm] = useState('');

term(搜索词)改变时,我们想要触发新的搜索。我们可以利用useEffect钩子,就像

  useEffect(() => {
    performSearch(`http://localhost:8080/books?q=${term}`)
  }, [term]);

我们可以在这里重新编写每一个axios.geterrorloading步骤,但是更明智的做法是重用我们已经定义的现有的useRemoteService。让我们先稍微调整一下:

-export const useRemoteService = (url, initialData) => {
+export const useRemoteService = (initialUrl, initialData) => {
   const [data, setData] = useState(initialData);
+  const [url, setUrl] = useState(initialUrl);
   const [loading, setLoading] = useState(false);
   const [error, setError] = useState(false);

@@ -22,7 +23,7 @@

     fetchBooks();
-  }, []);
+  }, [url]);

-  return {data, loading, error};
+  return {data, loading, error, setUrl};
 }

通过输出setUrl,我们给了外界一个改变url的机会。因为我们将[url]定义为fetchBooks效果的依赖,所以抓取将被触发。

也就是说我们只需要使用BookListContainer中的setUrl,剩下的工作由钩子来完成(图 7-1 ):

img/510354_1_En_7_Fig1_HTML.png

图 7-1

寻找一本书

  const [term, setTerm] = useState('');
  const {data, loading, error, setUrl} = useRemoteService('http://localhost:8080/books', );

  useEffect(() => {
    setUrl(`http://localhost:8080/books?q=${term}`)
  }, [term]);

注意,我们使用books?q=${e.target.value}作为获取数据的 URL。有json-server提供的全文搜索 API 你可以把books?q=domain发送到后端,它会返回所有包含该域名的内容。

您可以像这样在命令行上尝试:

curl http://localhost:8080/books?q=domain

现在,我们的测试又变绿了。让我们跳到Red-Green-Refactoring的下一步。

更进一步

假设有人想使用我们刚刚在本页完成的搜索框;怎么才能再利用呢?这很难,因为目前搜索框与BookListContainer中的其余代码紧密耦合,但是我们可以将其提取到另一个组件中,称为SearchBox:

import React from 'react';
import TextField from '@material-ui/core/TextField/TextField';

const SearchBox = ({term, onSearch}) => {
  return (<TextField
    label='Search'
    value={term}
    data-test='search'
    onChange={onSearch}
    margin='normal'
    variant='outlined'
  />)
};

export default SearchBox;

提取之后,BookListContainer变成

  const onSearch = (event) => setTerm(event.target.value);

  return (
    <SearchBox term={term} onSearch={onSearch}/>
    <BookList books={data} loading={loading} error={error}/>
  );

现在让我们添加一个单元测试:

import React from 'react';
import {render} from '@testing-library/react';
import userEvent from '@testing-library/user-event';

import SearchBox from './SearchBox';

describe('SearchBox', () => {
  it('renders input', () => {
    const props = {
      term: '',
      onSearch: jest.fn()
    }

    const {container} = render(<SearchBox {...props} />);
    const input = container.querySelector('input[type="text"]');
    userEvent.type(input, 'domain');

    expect(props.onSearch).toHaveBeenCalled();
  });
})

请注意,为了能够使用user-event,如果您还没有安装它,您必须安装:

yarn add @testing-library/user-event --dev

我们使用jest.fn()来创建一个spy对象,它可以记录调用的轨迹。我们使用userEvent.type API 模拟一个change事件,以domain作为有效负载。然后我们可以期待onChange方法已经被调用。

让我们在这里增加一个需求:当执行搜索时,我们不希望white-space成为请求的一部分。所以我们在字符串被发送到服务之前对其进行了处理。让我们先写一个测试:

  it('trim empty strings', () => {
    const props = {
      term: '',
      onSearch: jest.fn()
    }

    const {container} = render(<SearchBox {...props} />);
    const input = container.querySelector('input[type="text"]');
    userEvent.type(input, '  ');

    expect(props.onSearch).not.toHaveBeenCalled();
  })

它会失败,因为我们当前将所有的values发送给了books API。为了解决这个问题,我们可以在SearchBox中定义一个函数,在事件到达上层之前intercept:

  const protect = (event) => {
    const value = clone(event.target.value);
    if(!isEmpty(value.trim())) {
      return onSearch(event)
    }
  }

你会注意到我们使用了一些你以前可能没见过的函数——cloneisEmpty。这些将需要安装和从洛达什进口。

yarn add lodash.isempty lodash.clone

不要直接调用onSearch而是使用函数onChange,如图 7-2 所示,所有测试都应该通过:

  return (<TextField
    label='Search'
    value={term}
    data-test='search'
    onChange={protect}
    margin='normal'
    variant='outlined'
  />)

img/510354_1_En_7_Fig2_HTML.jpg

图 7-2

搜索框的单元测试

我们做了什么?

太好了,我们已经完成了所有三个功能!让我们快速回顾一下我们得到的信息:

  • 三个纯组件(BookDetail、BookList、SearchBox)及其单元测试

  • 两个容器组件(BookDetailContainer、BookListContainer)

  • 一个用于数据获取的定制钩子

  • 涵盖最有价值路径的四个验收测试(列表、细节和搜索)

走向

也许你已经在我们的end-to-end测试中注意到了一些代码味道。我们利用了许多新奇的commands,但没有准确表达我们在商业价值方面的所作所为:

it('Shows a book list', () => {
  cy.visit('http://localhost:3000/');
  cy.get('div[data-test="book-list"]').should('exist');
  cy.get('div.book-item').should((books) => {
    expect(books).to.have.length(3);

    const titles = [...books].map(x => x.querySelector('h2').innerHTML);
    expect(titles).to.deep.equal(['Refactoring', 'Domain-driven design', 'Building Microservices'])
  })
});

通过引入一些函数,我们可以显著提高可读性:

const gotoApp = () => {
  cy.visit('http://localhost:3000/');
}

const checkAppTitle = () => {
  cy.get('h2[data-test="heading"]').contains('Bookish');
}

在测试案例中,我们可以像这样使用它们:

  it('Visits the bookish', () => {
    gotoApp();
    checkAppTitle();
  });

对于复杂的函数,我们可以抽象得更多:

const checkBookListWith = (expectation = []) => {
  cy.get('div[data-test="book-list"]').should('exist');
  cy.get('div.book-item').should((books) => {
    expect(books).to.have.length(expectation.length);

    const titles = [...books].map(x => x.querySelector('h2').innerHTML);
    expect(titles).to.deep.equal(expectation)
  })
}

像这样使用它:

const checkBookList = () => {
  checkBookListWith(['Refactoring', 'Domain-driven design', 'Building Microservices', 'Acceptance Test Driven Development with React']);
}

或者

const checkSearchedResult = () => {
  checkBookListWith(['Domain-driven design'])
}

在我们提取了几个函数之后,一些模式出现了。我们可以做一些进一步的重构:

describe('Bookish application', () => {
  beforeEach(() => {
    feedStubBooks();
    gotoApp();
  });

  afterEach(() => {
    cleanUpStubBooks();
  });

  it('Visits the bookish', () => {
    checkAppTitle();
  });

  it('Shows a book list', () => {
    checkBookListWith(['Refactoring', 'Domain-driven design', 'Building Microservices']);
  });

  it('Goes to the detail page', () => {
    gotoNthBookInTheList(0);
    checkBookDetail();
  });

  it('Search for a title', () => {
    checkBookListWith(['Refactoring',
      'Domain-driven design',
      'Building Microservices',
      'Acceptance Test Driven Development with React']);
    performSearch('design');
    checkBookListWith(['Domain-driven design']);
  });

});

这看起来更整洁、更简洁。除了干净之外,我们还分离了业务价值和实现细节,这在将来可能会对我们有所帮助(例如,如果我们想要迁移到另一个测试框架或者重写它的某些部分,那么机构对读者来说是显而易见的)。

摘要

在前三章中,我们已经开发了应用Bookish的三个特性,并且我们已经了解了如何在实际项目中应用 ATDD。我们已经学习了如何快速设置react环境,以及如何使用模拟服务器来启动模拟服务。

我们引入了Cypress来写acceptance tests。一旦我们有了测试,我们就编写简单的代码使它通过,并在代码中发现代码味道时进行重构。在整个过程中,我们一直使用经典的Red-Green-Refactor循环。当我们重构时,我们根据职责和提取方法来拆分代码,重命名类,并重构文件夹,以使代码更加紧凑,更易于阅读和维护。

此外,我们已经为json-server添加了一些扩展,使我们能够在运行测试用例之前准备一些数据,并在测试完成后清理。这使得测试本身更具可读性和独立性。

最后,我们学习了如何将cypress命令重构为有意义的functions来提高可读性。

八、状态管理

很长一段时间,前端开发都是关于处理不同组件的状态同步。页面上两个搜索框中的关键字(一个在顶部,另一个在底部)、选项卡的活动状态、路由和 URL 中的散列、show more...链接等等。所有这些状态管理可能会令人难以置信地困惑。即使发明了像Backbone这样的MVVM库或双向数据绑定(一种在应用中共享数据的方式,使用它来监听事件并在父组件和子组件之间同时更新值),如果您必须管理不同组件之间的状态,事情仍然很有挑战性(如果有组件的话——在 jQuery 的世界中,没有真正的component,只有 DOM 片段)。

今天,web 开发是一个完全不同的场景。在典型的网页中,交互和数据转换变得更加复杂。处理这些并发症的方式也发生了变化。

典型的用户界面场景

让我们来看看图 8-1 中这个简单的页面。

img/510354_1_En_8_Fig1_HTML.jpg

图 8-1

许多组件共享相同的数据模型

右边是一个树组件,中间是一个图形组件。现在,当您单击树上的一个节点时,该节点应该根据其以前的状态折叠或展开,并且状态变化也应该同步到图表中。

如果您不想使用任何外部库,只使用来自 DOM API 的自定义事件可能会导致一个dead-loop——当您必须在graph上注册一个监听器来监听对tree的更改时,也要对树做同样的事情。当一个事件被触发时,它将在这两个组件之间来回切换。当你有两个以上的组件时,事情很快会变得更糟。

更可靠的方法是提取底层数据并使用发布-订阅模式:树和图都在监听数据的变化;一旦数据改变,组件应该重新呈现自己。

这种模式的实现现在很普遍;你几乎可以在每个网页上找到它。你可以实现自己的发布订阅库;然而,您可能会发现它很乏味,很难维护。幸运的是,我们有选择。

每当底层数据发生变化——无论是浏览器上的用户事件、计时器还是异步服务调用——我们都需要一种简单的方法来管理这些变化,并确保数据模型总是反映在所有组件的最新数据中。

Redux 简介

Redux是一个流行的 JavaScript 状态管理工具。

正如redux文档所述:

Redux 是 JavaScript 应用的可预测状态容器。

通过使用它,测试和调试您的应用变得简单明了,并且您可以轻松地跟踪它的状态。它没有绑定到任何库或框架,所以虽然您不必将它与React一起使用,但这是它最常见的实现。

冗余的三个原则

  • 真理的单一来源

  • 状态为只读

  • 变化是由纯函数产生的

Redux世界中,所有状态都存储在一个全局数据源中。在任何时候,这个数据源都可以映射到视图。当发生变化时——例如,用户点击一个按钮,发生超时,或者后端异步消息到达——将创建一个action,以描述发生了什么的对象的形式。

只是 JavaScript 对象形式的信息负载,将信息从我们的应用传输到我们的状态存储中。

一旦被创建,action将通过一个名为reducer的纯函数。reducer将指定应用状态如何响应动作而改变,这可能会触发视图的另一次重新呈现。它接受先前的状态和action并返回新的状态。图 8-2 清晰地展示了这一过程。

img/510354_1_En_8_Fig2_HTML.jpg

图 8-2

使用或不使用 redux 的应用。来源:Danny Huang(kuanhsuh。github。io/2017/09/28/What-s-Redux-and-how-to-use-it/

由于React提供的virtual dom机制,UI 会以最小的努力重新渲染。

解耦数据和视图

如果你仔细看看我们的useRemoteService钩子,你会注意到它实际上做了很多事情:

  1. 它向外部服务发出数据请求。

  2. 它负责 url 的更改。

  3. 它管理几种状态,包括loadingerror

export const useRemoteService = (initialUrl, initialData) => {
  const [data, setData] = useState(initialData);
  const [url, setUrl] = useState(initialUrl);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(false);

  useEffect(() => {
    const fetchBooks = async () => {
      setError(false);
      setLoading(true);

      try {
        const res = await axios.get(url);
        setData(res.data);
      } catch (e) {
        setError(true);
      } finally {
        setLoading(false);
      }
    };

    fetchBooks();
  }, [url]);

  return {data, loading, error, setUrl};
}

其中一些状态将总是一起更新,例如:

{
  data: [],
  loading: false,
  error: false
}

或者

{
  error: true
}

然而,在前面讨论的代码片段中,乍一看并不明显。

理想情况下,我们可以重写容器对象来触发一些数据获取动作,如BookDetailContainer中所示:

const BookDetailContainer = ({match}) => {
  const book = fetchBookById(match.params.id);
  // that will fetch data with `match.params.id`
  return (<BookDetail book={book}/>);
};

fetchBookById可以是同步函数调用,也可以是同步远程调用,但对于BookDetailContainer来说,关系不大。如前所述,在全局空间中,除了所有组件之外,还有一个维护应用状态的存储(类似于数据库)。每当在 UI 中的某个地方触发一个动作,并且发生一些修改时,相应的更新数据就会被发送到需要重新呈现的组件。

这就是状态管理容器可以帮助我们的方式。容器可以为我们处理细节,包括监听变化、分派动作、减少状态和广播变化。

视图= f(状态)

React社区中有一个众所周知的公式(有趣的是,这种模式似乎很久以前就已经在桌面 GUI 环境中讨论过了,更多内容请阅读底部的“Further Reading”部分):view = f(state),这意味着view只是state的一个函数。state这里展示了我们的应用状态的快照。例如,当用户打开Bookish主页时,该时间点的数据快照可能是

const state = {
    books: [
      {'name': 'Refactoring', 'id': 1, 'description': 'Refactoring'},
      {'name': 'Domain-driven design', 'id': 2, 'description': 'Domain-driven design'},
      {'name': 'Building Microservices', 'id': 3, 'description': 'Building Microservices'}
    ],
    term: ''
}

当用户在搜索框中键入Domain时,快照变成

const state = {
    books: [
      {'name': 'Domain-driven design', 'id': 2, 'description': 'Domain-driven design'}
    ],
    term: 'Domain'
}

这两段数据(状态)可以在某一点上代表整个应用。由于view = f(state),对于任何给定的stateview总是可预测的,所以应用开发人员唯一关心的是如何操作数据,因为UI将自动呈现。

我知道这听起来很简单,但它只是最近才出现在现实世界的应用中(第一次发布redux是在 2015 年 6 月,仅仅五年前)。

实施状态管理

为了使用 redux 处理应用的状态管理,我们需要处理所有三个组件:动作、reducer 和全局存储。让我们首先通过安装一些依赖项来设置环境。

环境设置

首先,我们需要添加一些包来使我们能够使用redux:

npm install redux redux-thunk history react-router-redux reselect --save

Action开始

redux中,动作是将数据从应用发送到商店的有效信息负载。它类似于其他 GUI 应用中的事件。要将此信息应用到商店,您必须dispatch(发送)它。

action是一个很好的切入点——它将促使我们考虑组件之间的交互方式,以及每个组件如何与外界交互。

BookListContainer为例。我们期望它有能力设置搜索的关键字。

创建一个名为redux的文件夹,在名为actions的子文件夹中,添加一个名为actions.test.js的文件:

import { setSearchTerm } from './actions'

describe('BookListContainer related actions', () => {
  it('Sets the search keyword', () => {
    const term = ''
    const expected = {
      type: 'SET_SEARCH_TERM',
      term
    }
    const action = setSearchTerm(term)
    expect(action).toEqual(expected)
  })
})

这个测试断言,当一个搜索词被提供给setSearchTerm动作创建者时,该动作将被创建。

顾名思义,动作创建者将创建一个动作,并且通常将与来自用户交互(鼠标点击、键盘)的事件绑定。

所以目前,setSearchTerm只是actions.js中的一个空函数,但是在这里实现非常简单:

export const setSearchTerm = (term) => {
  return { type: 'SET_SEARCH_TERM', term }
}

动作有一个type属性,表示正在执行的动作的类型,但是除此之外,动作对象的结构由我们来定义。

setSearchTerm接受一个搜索词,并返回一个类型为SET_SEARCH_TERM的动作,以及作为搜索词提供的任何字符串。

小菜一碟!

请注意,虽然我们在这里的商店中保存了 term,但我们不必这样做。这实际上是由您——开发人员——来决定将这些状态放在哪里。一个很好的经验法则是让store尽可能的简单和平坦。任何可以由其他字段计算的数据都不应该放在那里,而且在大多数情况下,其他人不关心的内部状态也应该放在组件内部。

异步操作

对于异步操作来说,事情变得有点棘手。为了让这些工作,我们需要配置redux-thunk并创建一个模拟store(用于测试)。

Redux-thunk是一个中间件(基本上,一个中间件可以拦截你发送的所有动作,并基于某种条件来存储和操作它们,例如,做一些审计或日志记录),它允许动作创建者返回一个函数而不是一个动作。这意味着我们可以延迟调度动作,或者只基于条件逻辑进行调度,从而允许我们处理异步动作。

让我们先将redux-mock-store添加到依赖项中:

npm install redux-mock-store --save-dev

在我们编写测试之前,我们将在actions.test.js中创建一个mockStore,如下所示:

import configureMockStore from 'redux-mock-store'
import thunk from 'redux-thunk'

const middlewares = [thunk]
const mockStore = configureMockStore(middlewares)

然后,让我们定义一条快乐的路径,假设网络正在运行,我们可以检索正在获取的数据(记住还要导入axios,因为我们将它用于网络请求):

it('Fetches data successfully', () => {
  const books = [
    { id: 1, name: 'Refactoring' },
    { id: 2, name: 'Domain-driven design' },
  ];
  axios.get = jest
    .fn()
    .mockImplementation(() => Promise.resolve({ data: books }));

  const expectedActions = [
    { type: 'FETCH_BOOKS_PENDING' },
    { type: 'FETCH_BOOKS_SUCCESS', books },
  ];
  const store = mockStore({ books: [] });

  return store.dispatch(fetchBooks('')).then(() => {
    expect(store.getActions()).toEqual(expectedActions);
  });
});

这里,我们期望fetchBooks将创建两个actions:一个表示请求已经发送,另一个表示响应已经收到。

因为请求在底层使用了axios,我们可以使用jest.fn().mockImplementation()来存根它。它将拦截对axios.get的调用,并调用我们定义的任何函数,所以我们不会在测试中发送真正的 HTTP 请求。

axios.get = jest.fn().mockImplementation(
  () => Promise.resolve({data: books}))

下面是actions.js里面的实现:

import axios from 'axios'

export const fetchBooks = () => {
  return (dispatch) => {
    dispatch({type: 'FETCH_BOOKS_PENDING'})
    return axios.get(`http://localhost:8080/books`).then((res) => {
      dispatch({type: 'FETCH_BOOKS_SUCCESS', books: res.data})
    })
  }
}

首先,我们dispatch一个FETCH_BOOKS_PENDING动作并调用axios.get。当承诺被解析时,我们可以用响应作为有效负载来dispatch?? 动作。

失败场景

对于网络故障的情况(例如,超时),我们可以在单元测试中再次使用jest.fn().mockImplementation():

axios.get = jest.fn().mockImplementation(
  () => Promise.reject({message: 'Something went wrong'}))

然后,验证失败的操作是否按预期调度:

it('Fetch data with error', () => {
  axios.get = jest
    .fn()
    .mockImplementation(() =>
       Promise.reject({ message: 'Something went wrong' })
    );

  const expectedActions = [
    { type: 'FETCH_BOOKS_PENDING' },
    { type: 'FETCH_BOOKS_FAILED', err: 'Something went wrong' },
  ];
  const store = mockStore({ books: [] });

  return store.dispatch(fetchBooks('')).then(() => {
    expect(store.getActions()).toEqual(expectedActions);
  });
});

我们可以在 promise rejected分支中添加一个catch案例来使我们的测试绿色化:

export const fetchBooks = (term) => {
  return (dispatch) => {
    dispatch({type: 'FETCH_BOOKS_PENDING'})
    return axios.get(`http://localhost:8080/books?q=${term}`).then((res) => {
      dispatch({type: 'FETCH_BOOKS_SUCCESS', books: res.data})
    }).catch((err) => {
      dispatch({type: 'FETCH_BOOKS_FAILED', err: err.message})
    })
  }
}

搜索动作

我们期望动作fetchBooks可以在发送请求时使用来自storeterm值作为关键字,这将启用过滤器功能。

请注意,我们正在将mockStore中的term设置为domain,并且需要更新 fetchBooks 操作以考虑所提供的查询参数:

it('Search data with term', () => {
  const books = [
    { id: 1, name: 'Refactoring' },
    { id: 2, name: 'Domain-driven design' },
  ];
  axios.get = jest
    .fn()
    .mockImplementation(() => Promise.resolve({ data: books }));

  const store = mockStore({ books: [] });

  return store.dispatch(fetchBooks('domain')).then(() => {
    expect(axios.get).toHaveBeenCalledWith(
      'http://localhost:8080/books?q=domain'
    );
  });
});

重构

action测试和实现中有很多硬编码和“神奇”的字符串。我们可以将它们提取到某个公共位置,这样就可以从那里引用它们。让我们创建一个名为types.js的文件:

export const SET_SEARCH_TERM = 'SET_SEARCH_TERM'
export const FETCH_BOOKS_PENDING = 'FETCH_BOOKS_PENDING'
export const FETCH_BOOKS_SUCCESS = 'FETCH_BOOKS_SUCCESS'
export const FETCH_BOOKS_FAILED = 'FETCH_BOOKS_FAILED'

并将它作为变量types导入到我们想要使用它的地方:

import * as types from './types'

然后,我们可以用types.FETCH_BOOKS_PENDING来引用它:

const expectedActions = [
  { type: types.FETCH_BOOKS_PENDING},
  { type: types.FETCH_BOOKS_SUCCESS, books }
]

还原剂

redux中,reducer只是一个纯函数——如果输入是确定的,那么输出总是可预测的。reducer负责阐明应用的状态将如何改变,以响应发送到商店的任何动作。

实现缩减器非常简单。比如FETCH_BOOKS_PENDINGFETCH_BOOK_SUCCESS可以这样测试,在reducers/reducer.test.js里面:

import reducer from './reducer';
import * as types from '../types';

describe('Reducer', () => {
  it('Show loading when request is sent', () => {
    const initState = { loading: false };

    const action = { type: types.FETCH_BOOKS_PENDING };
    const state = reducer(initState, action);

    expect(state.loading).toBeTruthy();
  });

  it('Add books to state when request successful', () => {
    const books = [
      { id: 1, name: 'Refactoring' },
      { id: 2, name: 'Domain-driven design' },
    ];

    const action = {
      type: types.FETCH_BOOKS_SUCCESS,
      books
    };

    const state = reducer([], action);
    expect(state.books).toBe(books);
  });
});

我们期望当FETCH_BOOKS_PENDING动作被发送到 reducer 时,它会将loading设置为true,并且FETCH_BOOKS_SUCCESS会附加响应(图书列表)来声明请求何时成功。

import * as types from '../types';

const reducer = (state = [], action) => {
  switch (action.type) {
    case types.FETCH_BOOKS_PENDING:
      return { ...state, loading: true };
    case types.FETCH_BOOKS_SUCCESS:
      return { books: action.books };
    default:
      return state;
  }
};

export default reducer;

测试 action creator 就像 Java 中的值-对象测试一样,测试reducer相当于测试静态util类。在React社区,人们倾向于将action+reducer+store作为集成测试一起测试。就我个人而言,我根本不直接测试那些代码。他们甚至在我的package.jsonmodulePathIgnorePatterns部分被明确忽略。

我们将在下一节详细讨论这一点。

Redux 的集成测试Store

在 src 文件夹中,创建store.test.js:

import axios from 'axios';

import * as actions from './redux/actions/actions';
import store from './store';

describe('Store', () => {
  const books = [
    {id: 1, name: 'Refactoring'}
  ]

  it('Fetch books from remote', () => {
    axios.get = jest.fn().mockImplementation(() => Promise.resolve({data: books}))

    return store.dispatch(actions.fetchBooks()).then(() => {
      const state = store.getState()
      expect(state.books.length).toEqual(1)
      expect(state.books).toEqual(books)
    })
  })
})

然后,我们创建store.js。我们导入前面定义的actions,创建一个真正的store来执行dispatch,并期望它返回correct响应。我们导入真实的reducers,并使用redux提供的createStore函数创建一个商店:

import { applyMiddleware, createStore, compose } from 'redux';
import thunk from 'redux-thunk';

import reducer from './redux/reducers/reducer';

const initialState = {};

const middlewares = [thunk]

const composedEnhancers = compose(
  applyMiddleware(...middlewares)
)

const store = createStore(
  reducer,
  initialState,
  composedEnhancers
)

export default store

这是我们的集成测试——它将action + reducer + store连接在一起。这种测试比其他单元测试稍微重一点,但是它提供了独特的价值:它证明了每个元素可以一起工作来提供预期的结果。

既然我们已经将动作和 reducers 连接到了状态,那么让我们更新我们的fetchBooks动作来使用状态。

-export const fetchBooks = (term) => {
+export const fetchBooks = () => {
- return (dispatch) => {
+ return (dispatch, getState) => {
    dispatch({ type: types.FETCH_BOOKS_PENDING });
+   const state = getState();
-   return axios.get(`http://localhost:8080/books?q=${term || ''}`).then((res) => {
+   return axios.get(`http://localhost:8080/books?q=${state.term || ''}`).then((res) => {
      dispatch({ type: types.FETCH_BOOKS_SUCCESS, books: res.data });
    }).catch((err) => {
        dispatch({type: types.FETCH_BOOKS_FAILED, err: err.message})
      });
  };
};

您还需要在Search data with term测试中更新 mockStore。

现在,我们可以为我们的searching功能添加另一个集成测试:

it('Performs a search', () => {
  axios.get = jest.fn().mockImplementation(() => Promise.resolve({data: books}))
  store.dispatch(actions.setSearchTerm('domain'))

  return store.dispatch(actions.fetchBooks()).then(() => {
    const state = store.getState()

    expect(state.term).toEqual('domain')
    expect(axios.get).toHaveBeenCalledWith('http://localhost:8080/books?q=domain')
  })
})

迁移应用

我们的下一步是将我们的应用迁移到redux。由于我们有足够的验收测试,我们不需要担心破坏任何功能。

首先,我们需要添加react-redux作为依赖项:

npm install react-redux

我们将store传递给index.js中的Provider组件。这意味着整个组件树可以在任何时候共享这个store:

+import { Provider } from 'react-redux';
+import store from './store';

-ReactDOM.render(<Router>
-  <App />
-</Router>, document.getElementById('root'));
+const root = <Provider store={store}>
+  <Router>
+    <App />
+  </Router>
+</Provider>
+
+ReactDOM.render(root, document.getElementById('root'));

由于presentational组件是无状态的,我们让它们保持原样——我们的迁移应该只影响container组件。对于BookListContainer,将会有很多变化,因为数据获取被委托给actions:

const dispatch = useDispatch();

useEffect(() => {
  dispatch(actions.fetchBooks(term))
}, [term]);

useDispatchreact-redux一起提供,可用于分派我们之前定义的动作—fetchBooks

每当BookListContainer中的term状态发生变化,就会触发fetchBooks,一旦服务器端返回数据,我们就可以用一个selector从状态中导出我们需要的数据。因为我们想让我们的商店尽可能精简,符合良好 redux 架构的原则,所以我们使用了selectorselector函数接受 Redux 存储状态作为参数,并根据该状态返回数据。

我们可以选择用来自reduxuseSelector来做这件事,就像

const books = useSelector(state => state.books);

或者定义一个函数来完成所有的映射。我更喜欢第二种选择,使用一个名为reselect的库,因为它提供了一个可组合的选择器和可缓存的结果(这意味着它将在内部保存计算出的值,除非值的依赖关系已经改变),以记忆的形式。这样,我们的应用可以更有性能,特别是对于具有相对较大存储空间的应用。让我们先将它安装到项目中:

npm install reselect

然后,我们在redux/selector中定义一个selector:

import { createSelector } from 'reselect';

const bookListSelector = createSelector([
  state => state.books,
  state => state.loading,
  state => state.error,
], (books, loading, error) => ({books, loading, error}));

export default bookListSelector;

createSelector接受两个参数,一个输入选择器数组和一个转换函数,并返回一个记忆的选择器。并不是说我们的 transform 函数现在不做太多的转换,只是直接从 state 返回值。

最后,将它们连接起来:

import bookListSelector from '../../redux/selectors/selector';

const BookListContainer = () => {
  const [term, setTerm] = useState();
  const dispatch = useDispatch();

  useEffect(() => {
    dispatch(actions.fetchBooks());
  }, [term, dispatch]);

  const onSearch = (event) => {
    dispatch(actions.setSearchTerm(event.target.value));
    dispatch(actions.fetchBooks());
  };

  const { books, loading, error } = useSelector(bookListSelector);

  return (

      <SearchBox term={term} onSearch={onSearch} />
      <BookList books={books} loading={loading} error={error} />

  );
};

测试容器

如果你仔细看看BookListContainer,你会发现在单元测试级别测试是相对困难的。这么说,我的意思是它依赖于一些外部组件,如actions甚至网络。

我们不想使用真实的网络,所以我们需要想出一种方法来模拟网络。幸运的是,axios-mock-adapter可以为我们做到这一点。

npm install axios-mock-adapter --save-dev

BookListContainer .test中,导入新的依赖项并创建一个新的 mock。我们可以通过调用onGet并在reply中提供预期的结果来定义模拟。在我们的例子中,我们需要从下游返回的两本书:

  it('renders', async () => {
    const mock = new MockAdapter(axios);
    mock.onGet('http://localhost:8080/books?q=').reply(200, [
      {'name': 'Refactoring', 'id': 1},
      {'name': 'Acceptance tests driven development with React', 'id': 2},
    ]);

    const {findByText} = renderWithProvider(<BookListContainer/>);

    const book1 = await findByText('Refactoring');
    const book2 = await findByText('Acceptance tests driven development with React');

    expect(book1).toBeInTheDocument();
    expect(book2).toBeInTheDocument();
  });

注意,我们使用了另一个包装函数——renderWithProvider——来避免在provider之外调用useDispatch所导致的错误。本质上,react-redux期望钩子在<Provider>内部被调用。

const renderWithProvider = (component) => {
  return {...render(<Provider store={store}>
      <Router>
        {component}
      </Router>
    </Provider>)}
};

在这里,我们使用与实际应用中相同的存储。当然,您也可以为测试定义一些静态存储。

此外,我们使用来自@testing-library/jest-domtoBeInTheDocument断言:

npm install @testing-library/jest-dom --save-dev

由于我们已经在cypress测试中介绍了这个功能,您可能想知道在这里测试这个功能有什么意义。这是因为我们可以用更快的反馈测试更多的案例。例如,如果我们想确保当网络故障发生时,我们应该在页面上看到一条error消息。由于网络故障的原因多种多样,在慢速cypress测试中测试每一种可能性并不理想。

相反,一个简单的integration test将为我们工作:

it('something went wrong', async () => {
  const mock = new MockAdapter(axios);
  mock.onGet('http://localhost:8080/books?q=').networkError();

  const {findByText} = renderWithProvider(<BookListContainer/>);
                                          const error = await findByText('Error...');

  expect(error).toBeInTheDocument();
})

通过使用axios-mock-adapter,您可以很容易地模拟不同的网络问题,甚至是数据形状改变的情况以及组件将如何处理它。

我们还需要向我们的 reducer 添加一个案例,以确保将错误状态添加到状态中。

case types.FETCH_BOOKS_FAILED:
return { ...state, loading: false, error: true };

获取图书详细信息

为了完成我们的迁移,我们需要为我们的BookDetailContainer创建一个action,而我们只需要一本书:

it('Fetch book by id', () => {
  const book = {id: 1, name: 'Refactoring'}
  axios.get = jest.fn().mockImplementation(() => Promise.resolve({data: book}))

  const store = mockStore({list: { books: [], term: '' }})

  return store.dispatch(fetchABook(1)).then(() => {
    expect(axios.get).toHaveBeenCalledWith('http://localhost:8080/books/1')
  })
})

我们可以复制fetchBooks,稍加修改就可以创造出fetchABook。它需要一个id参数来发送请求:

export const fetchABook = (id) => {
  return (dispatch) => {
    dispatch({type: types.FETCH_BOOK_PENDING})
    return axios.get(`http://localhost:8080/books/${id}`).then((res) => {
      dispatch({type: types.FETCH_BOOK_SUCCESS, book: res.data})
    }).catch((err) => {
      dispatch({type: types.FETCH_BOOK_FAILED, err: err.message})
    })
  }
}

store中的集成测试类似:

it('Fetch a book from remote', () => {
  axios.get = jest.fn().mockImplementation(() => Promise.resolve({data: books[0]}))

  return store.dispatch(actions.fetchABook(1)).then(() => {
    const state = store.getState()
    expect(state.book).toEqual(books[0])
  })
})

BookDetailContainer可以简化为

const BookDetailContainer = ({match}) => {
  const dispatch = useDispatch();

  useEffect(() => {
    dispatch(actions.fetchABook(match.params.id))
  }, []);

  const book = useSelector(state => state.detail);

  return (<BookDetail book={book}/>);
};

export default BookDetailContainer

由于这部分代码是由redux验证的,我们就不用测试了。我们的工作是确保BookDetailContainer正常工作:

describe('BookDetailContainer', () => {
  it('renders', async () => {
    const props = {
      match: {
        params: {
          id: 2
        }
      }
    };
    const mock = new MockAdapter(axios);
    mock.onGet('http://localhost:8080/books/2').reply(200, {
      'name': 'Acceptance tests driven development with React', 'id': 2
    });

    const {findByText} = renderWithProvider(<BookDetailContainer {...props} />);

    const book = await findByText('Acceptance tests driven development with React');
    expect(book).toBeInTheDocument();
  })
});

我们现在已经成功迁移到redux,测试覆盖看起来如图 8-3 。

img/510354_1_En_8_Fig3_HTML.jpg

图 8-3

测试覆盖报告

让我们看看我们在这里做了什么:

  • 动作、减速器的单元测试。

  • action + reducer + store的集成测试。

  • 验收测试保持绿色。

这真是一个伟大的成就。

摘要

在本章中,我们引入了redux作为状态管理机制。通过对actionreducer,进行单元测试,我们已经为我们的应用推出了必要的redux组件。经过一些重构,我们已经将我们的container代码迁移到了redux

在移植之后,我们发现我们的container易于测试,所以我们为它添加了一些单元测试。

这个test-last看起来有点奇怪,特别是在强调了首先编写我们的单元测试的重要性之后,但是如果你把它当作refactoring,的一部分,那就没问题了。当处理遗留代码时,我们总是会面临类似的问题。有时候,代码太难测试了;为了编写测试,您可能需要进行许多更改。在这种情况下,我们可以编写一个高级(验收)测试来确保业务需求总是得到满足。之后,我们可以refactor拆分当前的实现,然后我们添加适当的单元测试。

这些单元测试不仅验证功能,还作为文档,使其他团队成员通过查看组件的测试来理解如何使用组件成为可能。

进一步阅读

虽然为您的应用设计商店的形状具有挑战性,但您可能希望在这里获得一些关于如何以一种易于扩展和操作的方式来塑造它的见解: https://medium.com/javascript-scene/10-tips-for-better-redux-architecture-69250425af44

在他关于 GUI 架构的文章中,Martin Fowler 描述了观察者同步模式,这很像我们在网络世界中所做的: https://martinfowler.com/eaaDev/uiArchs.html

九、管理书评

在任何现实世界的项目中,您通常都必须处理某种类型的资源管理。广告管理系统通过在某种业务限制下创建、修改或删除项目来管理schedulecampaign。人力资源系统将通过创建(当公司有新员工时)、修改(被提升)和删除(退休)来帮助人力资源管理employee records。如果你观察这些系统试图解决的问题,你会发现一个相似的模式:它们都在一些资源上应用CRUD(创建、读取、更新、删除)操作。

然而,并不是所有的系统都必须包括所有的四种操作;对于一个关键系统,不会删除任何数据——程序员只会在记录中设置一个标志,将它们标记为已删除。记录仍然在那里,但是用户不能再从 GUI 中检索它们。

在这一章中,我们将学习如何通过扩展我们的应用bookish,在review资源上实现一组经典的CRUD操作,当然也应用了ATDD

业务需求

在图书详细信息页面中,有一些关于图书的关键信息,包括标题、描述和封面图片。然而,我们想要一些可以帮助最终用户找到更多关于这本书的东西——比如来自其他用户的reviews。一般来说,一本书可以有不止一个review。对这本书有强烈意见的读者会提供评论。评论可以是正面的,也可以是负面的。有时,也有一个评级与审查。

让我们从没有评论的最简单的场景开始。我们需要渲染一个空容器——我们称之为reviews-container

从一个空列表开始

import React from 'react';

import ReviewList from './ReviewList';
import { render } from '@testing-library/react';
import toBeInTheDocument from '@testing-library/jest-dom';

describe('ReviewList', () => {
  it('renders an empty list', () => {
    const props = {
      reviews: []
    };

    const {container} = render(<ReviewList {...props}/>);
    const reviews = container.querySelector('[data-test="reviews-container"]');

    expect(reviews).toBeInTheDocument();
  })
});

通过测试应该很简单:

import React from 'react';

const ReviewList = () => {
  return (<div data-test='reviews-container'></div>)
};

export default ReviewList;

呈现静态列表

我们的第二个测试案例可能涉及一些模拟数据:

  it('renders a list when data is passed', () => {
    const props = {
      reviews: [
        { name: 'Juntao', date: '2018/06/21', content: 'Excellent work, really impressed by your efforts'},
        { name: 'Abruzzi', date: '2018/06/22', content: 'What a great book'}
      ]
    };

    const {container} = render(<ReviewList {...props}/>);
    const reviews = container.querySelectorAll('[data-test="reviews-container"] .review');

    expect(reviews.length).toBe(2);
  })

在这里,我们演示了如何从外部使用组件(传入一组评论,每个评论都有用于name datecontent的字段)。其他程序员有可能不查看我们的实现就重用我们的组件。

一个简单的map应该对我们有用。由于地图要求key属性具有惟一的身份,所以让我们将namedate组合起来形成一个键;在下一节中,我们将在与后端 API 集成时创建一个id

import React from 'react';

const ReviewList = ({reviews}) => {
  return (<div data-test='reviews-container'>
    {
      reviews.map(review =>
      <div key={review.name + review.date} className='review'>{review.name}</div>)
    }
  </div>)
};

export default ReviewList;

我们需要确保内容正确呈现:

+
+    expect(reviews[0].innerHTML).toEqual('Juntao');

使用检查组件

对于我们的第一次集成,让我们将ReviewList放在BookDetail中。您现在可能已经知道,我们将首先实现测试。

我们可以在BookDetail.test.js中添加一个新的测试用例,因为我们想要验证 BookDetail 上是否有一个ReviewList

  it('renders reviews', () => {
    const props = {
      book: {
        name: 'Refactoring',
        description: 'Martin Fowler’s Refactoring defined core ideas and techniques that hundreds of thousands of developers have used to improve their software.',
        reviews: [
          { name: 'Juntao', date: '2018/06/21', content: 'Excellent work, really impressed by your efforts'}
        ]
      }
    };

    const {container} = render(<BookDetail {...props} />);

    const reviews = container.querySelectorAll('[data-test="reviews-container"] .review');
    expect(reviews.length).toBe(1);
    expect(reviews[0].innerHTML).toEqual('Juntao');
  });

注意这里的props包含一个reviews属性。对于实现,我们引入了ReviewList组件,由于组件化,就是这样:

import React from 'react';
import ReviewList from './ReviewList';

const BookDetail = ({book}) => {
  return (<div className='detail'>
    <h2 className='book-title'>{book.name}</h2>
    <p className='book-description'>{book.description}</p>
    {book.reviews && <ReviewList reviews={book.reviews}/>}
  </div>)
}

export default BookDetail;

填写书评表格

我们可以生成一些静态数据来显示在 BookDetail 组件中,但是如果我们能够显示一些来自最终用户的真实数据会更好。我们需要一个简单的形式让用户交流他们对这本书的观点。现在,我们可以提供两个输入框和一个提交按钮。第一个输入是用户名(或电子邮件地址),第二个输入(文本区域)是评论内容。

我们可以在BookDetail组件中添加一个新的测试用例:

  it('renders review form', () => {
    const props = {
      book: {
        name: 'Refactoring',
        description: 'Martin Fowler’s Refactoring defined core ideas and techniques that hundreds of thousands of developers have used to improve their software.'
      }
    };

    const {container} = render(<BookDetail {...props} />);

    const form = container.querySelector('form');
    const nameInput = container.querySelector('input[name="name"]');
    const contentTextArea = container.querySelector('textarea[name="content"]');
    const submitButton = container.querySelector('button[name="submit"]');

    expect(form).toBeInTheDocument();
    expect(nameInput).toBeInTheDocument();
    expect(contentTextArea).toBeInTheDocument();
    expect(submitButton).toBeInTheDocument();
  });

确保<form>显示在描述部分的下方和reviews的上方。TextFieldButton组件都可以从 material-ui 导入;

<form noValidate autoComplete='off'>
  <TextField
    label='Name'
    name='name'
    margin='normal'
    variant='outlined'
  />

  <TextField
    name='content'
    label='Content'
    margin='normal'
    variant='outlined'
    multiline
    rowsMax='4'
  />

  <Button variant='contained' color='primary' name='submit'>
    Submit
  </Button>
</form>

现在,我们必须将它连接到state:

+ const [name, setName] = useState('');
+ const [content, setContent] = useState('');

  return (<div className='detail'>
    <h2 className='book-title'>{book.name}</h2>
    <p className='book-description'>{book.description}</p>

    <form noValidate autoComplete='off'>
      <TextField
        label='Name'
        name='name'
        margin='normal'
        variant='outlined'
+       value={name}
+       onChange={e => setName(e.target.value)}
      />

      <TextField
        name='content'
        label='Content'
        margin='normal'
        variant='outlined'
        multiline
        rowsMax='4'
+       value={content}
+       onChange={e => setContent(e.target.value)}
      />

      <Button variant='contained' color='primary' name='submit'>
        Submit
      </Button>
    </form>

    {book.reviews && <ReviewList reviews={book.reviews}/>}
  </div>)
}

端到端测试

您可能已经注意到,当我们处理这个函数时,我们是从 ReviewList 组件的单元测试开始的。这是因为目前所有的变化都是静态的——在这一点上没有行为上的相互作用。在这种情况下,您可以从端到端测试开始——从上到下——或者从下到上。我更喜欢从组件本身开始,因为它能更快地提供反馈,帮助我们推动实现。

端到端测试可以这样描述:转到详细页面,找到输入字段,填写一些内容,然后单击 submit 按钮。最后,我们希望提交的内容将显示在页面上:

  it('Write a review for a book', () => {
    gotoNthBookInTheList(0);
    checkBookDetail('Refactoring');

    cy.get('input[name="name"]').type('Juntao Qiu');
    cy.get('textarea[name="content"]').type('Excellent work!');
    cy.get('button[name="submit"]').click();

    cy.get('div[data-test="reviews-container"] .review').should('have.length', 1);
  });

点击后测试将失败(图 9-1 ),因为它既不发送数据到服务器,也不接收响应和重新渲染。

img/510354_1_En_9_Fig1_HTML.png

图 9-1

尝试提交评论

为了让测试通过,我们需要回到redux并定义一个新类型的action

Redux 中的操作

我们已经知道,所有的网络活动和其他杂务都由redux中的action处理。所以让我们首先定义一个动作来创建一个review:

    it('Saves a review for a book', () => {
      const review = {
        name: 'Juntao Qiu',
        content: 'Excellent work!'
      }
      axios.post = jest.fn().mockImplementation(() => Promise.resolve({}))

      const store = mockStore({ books: [], term: '' })

      return store.dispatch(saveReview(1, review)).then(() => {
        expect(axios.post).toHaveBeenCalledWith('http://localhost:8080/books/1', review)
      })
    })

我们假设当我们将一些数据POST到端点http://localhost:8080/books/1时,将为 id 为1的书创建一个新的评论:

{
  "name": "Juntao Qiu",
  "content": "Excellent work!"
}

现在,使用axios创建异步动作对我们来说应该很容易:

export const saveReview = (id, review) => {
  return (dispatch) => {
    dispatch({type: types.SAVE_BOOK_REVIEW_PENDING})
    return axios.post(`http://localhost:8080/books/${id}`, review).then((res) => {
      dispatch({type: types.SAVE_BOOK_REVIEW_SUCCESS, payload: res.data})
    }).catch((err) => {
      dispatch({type: types.SAVE_BOOK_REVIEW_FAILED, err: err.message})
    })
  }
}

然后,我们在表单中的BookDetail组件中添加一个onClick事件处理程序:

  <Button
    variant='contained'
    color='primary'
    name='submit'
    onClick={() => dispatch(actions.saveReview(book.id, {name, content}))}
  >
    Submit
  </Button>

因为useDispatch只能在Provider中使用,所以BookDetail的单元测试现在失败了。我们可以通过以下方式解决这个问题

import store from '../../store';
import {Provider} from 'react-redux';

const renderWithProvider = (component) => {
  return {...render(<Provider store={store}>
        {component}
    </Provider>)}
};

在使用render的地方使用renderWithProvider:

const { container } = renderWithProvider(<BookDetail {...props} />);

JSON-服务器定制

我们一直在使用json-server来为我们简化后端 API 工作。我们需要对它进行更多的定制,以适应我们的新要求。我们期望review是一本书的子资源,这允许我们通过请求/books/1/reviews来访问属于某本书的所有评论。

此外,我们希望/books/1在响应中携带所有的reviews作为嵌入资源。这将使图书详细信息页面的呈现变得简单方便。为了做到这一点,我们需要像这样定义json-server中的route:

server.use(jsonServer.rewriter({
  '/books/:id':  '/books/:id?_embed=reviews'
}))

server.use(router)

然后,无论何时访问/books/1,它都会返回所有评论和回复。

像这样的请求

curl http://localhost:8080/books/1

会得到这样的回应

{
  "name": "Refactoring",
  "id": 1,
  "description": "Refactoring",
  "reviews": [
    {
      "name": "Juntao",
      "content": "Very great book",
      "bookId": 1,
      "id": 1
    }
  ]
}

干得好!同样,当我们将一些数据POSThttp://localhost:8080/books/1/reviews时,它会在 id 为1的书下创建一个review

现在,我们可以通过表单创建评论。请注意,存根服务器返回 201,表示审查已被接受(图 9-2 )。

img/510354_1_En_9_Fig2_HTML.jpg

图 9-2

提交第一本书的评论

当然,我们需要在提交后刷新页面,以查看新创建的评论:

export const saveReview = (id, review) => {
  const config = {
    headers: { 'Content-Type': 'application/json' }
  }

  return (dispatch) => {
    dispatch({type: types.SAVE_BOOK_REVIEW_PENDING})
    return axios.post(`http://localhost:8080/books/${id}/reviews`, JSON.stringify(review), config).then((res) => {
      dispatch({type: types.SAVE_BOOK_REVIEW_SUCCESS, payload: res.data})
      dispatch(fetchABook(id));
    }).catch((err) => {
      dispatch({type: types.SAVE_BOOK_REVIEW_FAILED, err: err.message})
    })
  }
}

注意这里我们在成功回调中添加了dispatch (fetchABook(id))。它为我们刷新了reviews。然而,当您重新运行测试时,review的创建将会失败,因为我们没有在测试用例执行后进行清理。

为了解决这个问题(重复 id),首先,我们需要在server.js中定义一个 map:

const relations = {
  'books': 'reviews'
}

和一个生成embed定义的函数,所以由给定的relations动态生成一个route:

const buildRewrite = (relations) => {
  return _.reduce(relations, (sum, embed, resources) => {
    sum[`/${resources}/:id`] = `/${resources}/:id?_embed=${embed}`
    return sum;
  }, {})
}

server.use(jsonServer.rewriter(buildRewrite(relations)))

现在,我们可以通过在DELETE中增加一个额外的步骤来清理嵌入式资源。首先,我们检查需要删除的资源是否有任何embedded资源。如果有,我们会把它们和资源一起清理掉。

server.use((req, res, next) => {
  if (req.method === 'DELETE' && req.query['_cleanup']) {
    const db = router.db
    db.set(req.entity, []).write()

    if (relations[req.entity]) {
      db.set(relations[req.entity], []).write()
    }

    res.sendStatus(204)
  } else {
    next()
  }
})

然后,我们可以使用afterEach来完成所有的清理工作,就像之前一样:

  afterEach(() => {
    return axios.delete('http://localhost:8080/books?_cleanup=true').catch(err => err)
  })

现在,我们不必担心一个失败的测试会导致另一个测试的问题。

重构

我们现在已经完成了Review的创建和检索。我们的测试覆盖率仍然很高,这很好。有了这些测试,我们就可以自信无畏地重构了。对于BookDetail组件,form是独立的,应该有自己的文件:

const ReviewForm = ({id}) => {
  const [name, setName] = useState('');
  const [content, setContent] = useState('');

  const dispatch = useDispatch();

  return (<form noValidate autoComplete='off'>
    <TextField
      label='Name'
      name='name'
      margin='normal'
      variant='outlined'
      value={name}
      onChange={e => setName(e.target.value)}
    />

    <TextField
      name='content'
      label='Content'
      margin='normal'
      variant='outlined'
      multiline
      rowsMax='4'
      value={content}
      onChange={e => setContent(e.target.value)}
    />

    <Button variant='contained' color='primary' name='submit' onClick={() => dispatch(actions.saveReview(id, {name, content}))}>
      Submit
    </Button>
  </form>)
}

export default ReviewForm;

提取之后,BookDetail就干净多了:

const BookDetail = ({book}) => {
  return (<div className='detail'>
    <h2 className='book-title'>{book.name}</h2>
    <p className='book-description'>{book.description}</p>

    <ReviewForm id={book.id} />

    {book.reviews && <ReviewList reviews={book.reviews}/>}
  </div>)
}

而对于cypress中的功能测试,我们可以提取一些辅助函数来简化测试用例:

  it('Write a review for a book', () => {
    gotoNthBookInTheList(0);
    checkBookDetail();
    composeReview('Juntao Qiu', 'Excellent work!');
    checkReview();
  });

功能composeReviewcheckReview定义如下

export const composeReview = (name, content) => {
  cy.get('input[name="name"]').type(name);
  cy.get('textarea[name="content"]').type(content);
  cy.get('button[name="submit"]').click();
};

export const checkReview = () => {
  cy.get('div[data-test="reviews-container"] .review').should('have.length', 1);
}

现在,审查表单应该类似于图 9-3 。

img/510354_1_En_9_Fig3_HTML.jpg

图 9-3

评论页面

添加更多字段

如果你仔细看看这篇评论,你会发现遗漏了一些重要信息:用户名和创建时间。我们需要完成这些字段:

     expect(reviews.length).toBe(2);

-    expect(reviews[0].innerHTML).toEqual('Juntao');
+    expect(reviews[0].querySelector('.name').innerHTML).toEqual('Juntao');
+    expect(reviews[0].querySelector('.date').innerHTML).toEqual('2018/06/21');
+    expect(reviews[0].querySelector('.content').innerHTML).toEqual('Excellent work, really impressed by your efforts');
   })

实施应该毫不费力:

   return (<div data-test='reviews-container'>
     {
       reviews.map((review, index) =>
-      <div key={index} className='review'>{review.name}</div>)
+      <div key={index} className='review'>
+        <span className='name'>{review.name}</span>
+        <span className='date'>{review.date}</span>
+        <p className='content'>{review.content}</p>
+      </div>)
     }
   </div>)

随着map中的代码不断增长,我们可以将其提取到一个单独的文件—Review:

import React from 'react';

const Review = ({review}) => (<div className='review'>
  <span className='name'>{review.name}</span>
  <span className='date'>{review.date}</span>
  <p className='content'>{review.content}</p>
</div>);

export default Review;

并将其作为一个纯粹的presentational组件:

import Review from './Review';

const ReviewList = ({reviews = []}) => {
  return (<div data-test='reviews-container'>
    {
      reviews.map((review, index) => <Review key={index} review={review}/>)
    }
  </div>)
};

由于所有呈现审查的逻辑都被移到了它自己的组件中,我们也可以移动相应的测试。

import Review from './Review';

describe('Review', () => {
  it('renders', () => {
    const props = {
      review: {
        name: 'Juntao',
        date: '2018/06/21',
        content: 'Excellent work, really impressed by your efforts'
      },
    };

    const {container} = render(<Review {...props}/>);
    const review = container.querySelector('.review');

    expect(review.querySelector('.name').innerHTML).toEqual('Juntao');
    expect(review.querySelector('.date').innerHTML).toEqual('2018/06/21');
    expect(review.querySelector('.content').innerHTML)
      .toEqual('Excellent work, really impressed by your efforts');
  })
});

这样,我们测试不同的数据变量就容易多了。例如,如果明天产品所有者决定他们想要以relative的方式显示日期,例如Posted 5 mins agoPosted yesterday,而不是absolute日期,我们根本不需要触摸ReviewList

所有测试都顺利通过——太棒了!我们的代码更简洁,更有凝聚力,责任更明确。此外,我们的高测试覆盖率意味着我们在重构时不必担心破坏现有的功能。

审阅编辑

Review组件现在提供了基本的表示。然而,在现实世界中,用户可能会在他们的评论中留下一个错别字,或者完全重写内容。我们需要允许用户编辑他们已经发布的Review

我们需要添加一个Edit按钮,点击后会变成一个Submit按钮(等待用户提交)。当用户点击Submit时,文本再次变成Edit。所以第一个测试可能是

  it('editing', () => {
    const props = {
      review: {
        name: 'Juntao',
        date: '2018/06/21',
        content: 'Excellent work, really impressed by your efforts'
      },
    };

    const {getByText} = render(<Review {...props}/>);
    const button = getByText('Edit');

    expect(button.innerHTML).toEqual('Edit');

    userEvent.click(button);

    expect(button.innerHTML).toEqual('Submit');
  });

通过使用userEvent.click,我们可以模拟Edit按钮上的点击事件,并验证按钮上的文本变化。我们可以通过在组件中引入state来实现这一点:

const [editing, setEditing] = useState(false);

我们需要做的就是切换editing的状态。对于渲染,我们可以通过editing状态来决定显示哪个文本,如下所示:

<Button variant='contained' color='primary' name='submit' onClick={() => setEditing(!editing)}>
  {!editing ? 'Edit' : 'Submit'}
</Button>

我们希望有一个当用户点击Edit时显示的textarea,并将所有评论内容复制到textarea中进行编辑:

  it('copy content to a textarea for editing', () => {
    const props = {
      review: {
        name: 'Juntao',
        date: '2018/06/21',
        content: 'Excellent work, really impressed by your efforts'
      },
    };

    const {getByText, container} = render(<Review {...props}/>);
    const button = getByText('Edit');
    const content = container.querySelector('p.content');
    const editingContent = container.querySelector('textarea[name="content"]');

    expect(content).toBeInTheDocument();
    expect(container.querySelector('textarea[name="content"]')).not.toBeInTheDocument();

    userEvent.click(button);

    expect(content).not.toBeInTheDocument();

    expect(container.querySelector('textarea[name="content"]')).toBeInTheDocument();
    expect(container.querySelector('textarea[name="content"]').innerHTML)
      .toEqual('Excellent work, really impressed by your efforts');
  });
})

为了实现这一点,我们还必须维护state中的内容:

const [content, setContent] = useState(review.content);

并根据editing状态渲染textareastatic text:

    {!editing ? <p className='content'>{review.content}</p> : (<TextField
      name='content'
      label='Content'
      margin='normal'
      variant='outlined'
      multiline
      rowsMax='4'
      value={content}
      onChange={e => setContent(e.target.value)}
    />)}

现在,评审有两种不同的状态:viewingediting,可以通过点击.edit按钮进行切换。为了将实际内容保存到后端,我们需要定义一个action

保存审核-行动

就像创建评论的过程一样,为了保存评论,我们需要向后端发送一个请求。好消息是json-server已经提供了这个功能。我们向http://localhost:8080/reviews/{id}发送PUT请求来更新评论。当然,我们必须首先为 redux 操作编写一个测试:

    it('Update a review for a book', () => {
      const config = {
        headers: { 'Content-Type': 'application/json' }
      }

      const review = {
        name: 'Juntao Qiu',
        content: 'Excellent work!'
      }

      axios.put = jest.fn().mockImplementation(() => Promise.resolve({}))

      const store = mockStore({list: { books: [], term: '' }})

      return store.dispatch(updateReview(1, review)).then(() => {
        expect(axios.put).toHaveBeenCalledWith('http://localhost:8080/reviews/1', JSON.stringify(review), config)
      })
    })

注意,我们在这里嘲讽了axios.put。一般来说,当您更新一些现有的资源时,您使用PUT作为 HTTP 动词。

export const updateReview = (id, review) => {
  const config = {
    headers: { 'Content-Type': 'application/json' }
  }

  return (dispatch) => {
    dispatch({type: types.SAVE_BOOK_REVIEW_PENDING})
    return axios.put(`http://localhost:8080/reviews/${id}`, JSON.stringify(review), config).then((res) => {
      dispatch({type: types.SAVE_BOOK_REVIEW_SUCCESS, payload: res.data})
    }).catch((err) => {
      dispatch({type: types.SAVE_BOOK_REVIEW_FAILED, err: err.message})
    })
  }
}

注意,我们在这里重用了SAVE_BOOK_REVIEW类型。

综合

既然编辑评论的所有部分都准备好了,就该把它们放在一起了。我们需要确保当点击Submit时,调用save action:

  //...
  const props = {
    bookId: 123,
    review: {
      name: 'Juntao',
      date: '2018/06/21',
      content: 'Excellent work, really impressed by your efforts'
    },
  };

  const {getByText, container} = renderWithProvider(<Review {...props}/>);

  userEvent.click(getByText('Edit'));

  const content = container.querySelector('textarea[name="content"]');
  userEvent.type(content, 'Fantastic work');

  userEvent.click(getByText('Submit'));
  //...

现在,剩下的唯一问题是,无论何时单击按钮,我们如何验证调用了正确的操作。jest提供了设置mockstub的各种方式。在我们这里的例子中,我们可以import真实的动作,然后override它的行为,所以我们不发送真实的网络请求:

import * as actions from '../redux/actions/actions';

const fakeUpdateReview = () => {
  return () => {
    return Promise.resolve({})
  }
};

jest.spyOn(actions, 'updateReview').mockImplementation(() => fakeUpdateReview);

最后,我们可以验证已经调用了updateReview:

  it('send requests', async () => {
    const fakeUpdateReview = () => {
      return () => {
        return Promise.resolve({})
      }
    };

    jest.spyOn(actions, 'updateReview').mockImplementation(() => fakeUpdateReview);

    //...
    const {getByText, container} = renderWithProvider(<Review {...props}/>);

    userEvent.click(getByText('Edit'));

    const content = container.querySelector('textarea[name="content"]');
    userEvent.type(content, 'Fantastic work');

    userEvent.click(getByText('Submit'));

    expect(actions.updateReview).toHaveBeenCalledWith(123, {content: 'Fantastic work'});
  })

因为updateReview的正确性已经在action测试中得到验证,所以我们可以对它的功能充满信心。现在让我们尝试编写实现:

+import {useDispatch} from 'react-redux';

+import * as actions from '../redux/actions/actions';

+const Review = ({review}) => {
   const [editing, setEditing] = useState(false);
   const [content, setContent] = useState(review.content);

+  const dispatch = useDispatch();
+
+  const clickHandler = () => {
+    if(editing) {
+      dispatch(actions.updateReview(review.id, {content}))
+    }
+
+    setEditing(!editing);
+  };
+
   return (<div className='review'>

我们用useDispatch React 钩子从react-redux生成一个dispatch,然后用它触发一个真实的action(图 9-4 )。

img/510354_1_En_9_Fig4_HTML.jpg

图 9-4

Review page 现在可以很好地使用 redux 了

摘要

太棒了,我们已经完成了整个Review部分。这是一个相对较大的组件,有一个Reviews列表,在每个部分,我们允许用户添加一个新的Review,以及编辑现有的。

在这个过程中,我们尝试了一种不同的方法来处理TDD——首先编写unit tests来驱逐一个分离的Component,然后是分离的actions,最后是一个集成测试,它可以确保我们将它们连接在一起。