跳到内容

d

测试与扩展我们的应用

现在我们已经为我们的项目建立了一个良好的基础,是时候开始扩展它了。在这一节中,你可以把你到目前为止获得的所有React Native知识运用起来。在扩展我们的应用的同时,我们将涵盖一些新的领域,如测试,和额外的资源。

Testing React Native applications

要开始测试任何类型的代码,我们首先需要的是一个测试框架,我们可以用它来运行一组测试案例并检查其结果。对于测试JavaScript应用,Jest是这种测试框架的一个流行的候选人。对于用Jest测试基于Expo的React Native应用,Expo以jest-expo预设的形式提供了一套Jest配置。为了在Jest的测试文件中使用ESLint,我们还需要ESLint的eslint-plugin-jest插件。让我们开始安装这些软件包。

npm install --save-dev jest jest-expo eslint-plugin-jest

为了在Jest中使用jest-expo预设,我们需要在package.json文件中加入以下Jest配置,同时加入test脚本。

{
  // ...
  "scripts": {
    // other scripts...
    "test": "jest"  },
  "jest": {    "preset": "jest-expo",    "transform": {      "^.+\\.jsx?$": "babel-jest"    },    "transformIgnorePatterns": [      "node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|react-native-svg|react-router-native)"    ]  },  // ...
}

transform选项告诉Jest用Babel编译器来转换.js.jsx文件。transformIgnorePatterns选项用于在转换文件时忽略node_modules目录中的某些目录。这个Jest配置与Expo's document中提出的配置几乎相同。

为了在ESLint中使用eslint-plugin-jest插件,我们需要把它包含在.eslintrc文件的插件和扩展数组中。

{
  "plugins": ["react", "react-native"],
  "settings": {
    "react": {
      "version": "detect"
    }
  },
  "extends": ["eslint:recommended", "plugin:react/recommended", "plugin:jest/recommended"],  "parser": "@babel/eslint-parser",
  "env": {
    "react-native/react-native": true
  },
  "rules": {
    "react/prop-types": "off",
    "react/react-in-jsx-scope": "off"
  }
}

要看到这个设置是有效的,在src目录下创建一个目录__tests_,并在创建的目录下创建一个文件example.js。在该文件中,添加这个简单的测试。

describe('Example', () => {
  it('works', () => {
    expect(1).toBe(1);
  });
});

现在,让我们通过运行npm test来运行我们的测试实例。该命令的输出应该表明,位于src/\tests_/example.js文件中的测试已经通过。

Organizing tests

在一个单一的_tests\目录中组织测试文件是组织测试的一种方法。当选择这种方法时,建议把测试文件放在其相应的子目录中,就像代码本身一样。这意味着,例如与组件相关的测试放在components目录下,与实用程序相关的测试放在utils目录下,等等。这将导致以下的结构。

src/
  __tests__/
    components/
      AppBar.js
      RepositoryList.js
      ...
    utils/
      authStorage.js
      ...
    ...

另一种方法是在实现附近组织测试。这意味着,例如,包含AppBar组件测试的测试文件与该组件的代码在同一目录下。这将导致以下的结构。

src/
  components/
    AppBar/
      AppBar.test.jsx
      index.jsx
    ...
  ...

在这个例子中,组件的代码在index.jsx文件中,测试在AppBar.test.jsx文件中。注意,为了让Jest找到你的测试文件,你必须把它们放到_tests\目录下,使用.test.spec后缀,或者手动配置全局模式。

Testing components

现在我们已经成功地设置了Jest并运行了一个非常简单的测试,现在是时候了解如何测试组件了。正如我们所知,测试组件需要一种方法来序列化一个组件的渲染输出,并模拟发射不同类型的事件,如按下按钮。为了这些目的,有一个测试库系列,它提供了用于测试不同平台上的用户界面组件的库。所有这些库都共享类似的API,用于以用户为中心的方式测试用户界面组件。

第五章节中,我们熟悉了这些库中的一个,即React Testing Library。不幸的是,这个库只适用于测试React网络应用。幸运的是,这个库存在一个与React Native对应的库,那就是React Native Testing Library。这就是我们在测试React Native应用的组件时要使用的库。好消息是,这些库共享一个非常相似的API,所以没有太多的新概念需要学习。除了React Native测试库,我们还需要一组React Native特定的Jest匹配器,如toHaveTextContenttoHaveProp。这些匹配器由jest-native库提供。在进入细节之前,让我们先安装这些包。

npm install --save-dev react-test-renderer@17.0.1 @testing-library/react-native @testing-library/jest-native

NB: 如果你面临同行的依赖问题,请确保react-test-renderer的版本与上述npm install命令中的项目的React版本相匹配。你可以通过运行npm list react --depth=0检查React版本。

如果安装失败是由于对等延迟问题,请使用--legacy-peer-deps标志与npm install命令再次尝试。

为了能够使用这些匹配器,我们需要扩展Jest的expect对象。这可以通过使用一个全局设置文件来完成。在你项目的根目录下创建一个文件setupTests.js,也就是package.json文件所在的同一目录。在该文件中添加以下一行。

import '@testing-library/jest-native/extend-expect';

接下来,在package.json文件中把这个文件配置为Jest's配置的设置文件(注意,路径中的是故意的,不需要替换)。

{
  // ...
  "jest": {
    "preset": "jest-expo",
    "transform": {
      "^.+\\.jsx?$": "babel-jest"
    },
    "transformIgnorePatterns": [
      "node_modules/(?!(jest-)?react-native|react-clone-referenced-element|@react-native-community|expo(nent)?|@expo(nent)?/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|@sentry/.*|react-router-native)"
    ],
    "setupFilesAfterEnv": ["<rootDir>/setupTests.js"]  }
  // ...
}

React Native测试库的主要概念是queryfiring events。查询是用来从使用render函数渲染的组件中提取一组节点的。查询在测试中非常有用,例如,我们希望一些文本,如仓库的名称,能出现在渲染的组件中。下面是一个例子,如何使用ByText查询来检查组件的Text元素是否有正确的文本内容。

import { Text, View } from 'react-native';
import { render } from '@testing-library/react-native';

const Greeting = ({ name }) => {
  return (
    <View>
      <Text>Hello {name}!</Text>
    </View>
  );
};

describe('Greeting', () => {
  it('renders a greeting message based on the name prop', () => {
    const { debug, getByText } = render(<Greeting name="Kalle" />);

    debug();

    expect(getByText('Hello Kalle!')).toBeDefined();
  });
});

React Native Testing Library's documentation有一些关于如何查询不同种类的元素的好提示。另一个值得阅读的指南是Kent C. Dodds的文章Making your UI tests resilient to change

render函数返回查询和额外的辅助工具,例如debug函数。debug函数以用户友好的格式打印出渲染的React树。如果你不确定render函数所渲染的React树是什么样子的,可以使用它。我们通过使用getByText函数获得包含某些文本的Text节点。关于所有可用的查询,请查看React Native Testing Library's documenttoHaveTextContent匹配器用于断定节点的文本内容是正确的。可用的React Native特定匹配器的完整列表可以在jest-native库的文档中找到。Jest's document 包含了所有通用的Jest匹配器。

第二个非常重要的React Native测试库概念是发射事件。我们可以通过使用fireEvent对象的方法在所提供的节点中触发一个事件。这对于在文本字段中输入文本或按下按钮是很有用的。下面是一个如何测试提交一个简单表单的例子。

import { useState } from 'react';
import { Text, TextInput, Pressable, View } from 'react-native';
import { render, fireEvent } from '@testing-library/react-native';

const Form = ({ onSubmit }) => {
  const [username, setUsername] = useState('');
  const [password, setPassword] = useState('');

  const handleSubmit = () => {
    onSubmit({ username, password });
  };

  return (
    <View>
      <View>
        <TextInput
          value={username}
          onChangeText={(text) => setUsername(text)}
          placeholder="Username"
        />
      </View>
      <View>
        <TextInput
          value={password}
          onChangeText={(text) => setPassword(text)}
          placeholder="Password"
        />
      </View>
      <View>
        <Pressable onPress={handleSubmit}>
          <Text>Submit</Text>
        </Pressable>
      </View>
    </View>
  );
};

describe('Form', () => {
  it('calls function provided by onSubmit prop after pressing the submit button', () => {
    const onSubmit = jest.fn();
    const { getByPlaceholderText, getByText } = render(<Form onSubmit={onSubmit} />);

    fireEvent.changeText(getByPlaceholderText('Username'), 'kalle');
    fireEvent.changeText(getByPlaceholderText('Password'), 'password');
    fireEvent.press(getByText('Submit'));

    expect(onSubmit).toHaveBeenCalledTimes(1);

    // onSubmit.mock.calls[0][0] contains the first argument of the first call
    expect(onSubmit.mock.calls[0][0]).toEqual({
      username: 'kalle',
      password: 'password',
    });
  });
});

在这个测试中,我们要测试在使用fireEvent.changeText方法填充表单字段并使用fireEvent.press方法按下提交按钮后,onSubmit回调函数被正确调用。为了检查onSubmit函数是否被调用,以及使用哪些参数,我们可以使用一个mock function。模拟函数是具有预编程行为的函数,例如一个特定的返回值。此外,我们还可以为模拟函数创建期望值,如 "期望模拟函数被调用一次"。可用期望的完整列表可以在Jest's expect documentation中找到。

在进一步进入测试React Native应用的世界之前,通过在我们之前创建的_tests\目录中添加一个测试文件来玩玩这些例子。

Handling dependencies in tests

前面例子中的组件很容易测试,因为它们或多或少都是的。纯粹的组件不依赖于副作用,如网络请求或使用一些本地API,如AsyncStorage。Form组件比Greeting组件更不纯粹,因为它的状态变化可以被算作一个副作用。尽管如此,测试它并不难。

接下来,让我们看一下测试有副作用的组件的策略。让我们从我们的应用中挑选RepositoryList组件作为例子。目前,该组件有一个副作用,即用于获取审查过的存储库的GraphQL查询。目前RepositoryList组件的实现看起来是这样的。

const RepositoryList = () => {
  const { repositories } = useRepositories();

  const repositoryNodes = repositories
    ? repositories.edges.map((edge) => edge.node)
    : [];

  return (
    <FlatList
      data={repositoryNodes}
      // ...
    />
  );
};

export default RepositoryList;

唯一的副作用是使用useRepositories钩子,它发送了一个GraphQL查询。有几种方法可以测试这个组件。一种方法是按照Apollo客户端的文档中的指示模拟Apollo客户端的响应。一个更简单的方法是假设useRepositories钩子按预期工作(最好是通过测试),并将组件的 "纯 "代码提取到另一个组件中,如RepositoryListContainer组件。

export const RepositoryListContainer = ({ repositories }) => {
  const repositoryNodes = repositories
    ? repositories.edges.map((edge) => edge.node)
    : [];

  return (
    <FlatList
      data={repositoryNodes}
      // ...
    />
  );
};

const RepositoryList = () => {
  const { repositories } = useRepositories();

  return <RepositoryListContainer repositories={repositories} />;
};

export default RepositoryList;

现在,RepositoryList组件只包含副作用,它的实现也很简单。我们可以测试RepositoryListContainer组件,通过repositoriesprop向它提供分页的仓库数据,并检查渲染的内容是否有正确的信息。

Extending our application

现在是时候把我们到目前为止所学到的东西都用好,开始扩展我们的应用了。我们的应用仍然缺乏一些重要的功能,如审查一个仓库和注册一个用户。接下来的练习将集中在这些基本功能上。

Cursor-based pagination

当API从某个集合中返回一个有序的项目列表时,它通常会返回整个项目集的一个子集,以减少所需的带宽,并降低客户端应用的内存使用。所需的项目子集可以被参数化,这样客户端就可以请求例如列表中某个索引后的前20个项目。这种技术通常被称为分页。当项目可以在由游标定义的某个项目之后被请求时,我们谈论的就是基于游标的分页

所以游标只是一个有序列表中的项目的序列化渲染。让我们看一下由repositories查询返回的分页的存储库,使用以下查询。

{
  repositories(first: 2) {
    totalCount
    edges {
      node {
        id
        fullName
        createdAt
      }
      cursor
    }
    pageInfo {
      endCursor
      startCursor
      hasNextPage
    }
  }
}

first参数告诉API只返回前两个存储库。下面是一个查询结果的例子。

{
  "data": {
    "repositories": {
      "totalCount": 10,
      "edges": [
        {
          "node": {
            "id": "zeit.next.js",
            "fullName": "zeit/next.js",
            "createdAt": "2020-05-15T11:59:57.557Z"
          },
          "cursor": "WyJ6ZWl0Lm5leHQuanMiLDE1ODk1NDM5OTc1NTdd"
        },
        {
          "node": {
            "id": "zeit.swr",
            "fullName": "zeit/swr",
            "createdAt": "2020-05-15T11:58:53.867Z"
          },
          "cursor": "WyJ6ZWl0LnN3ciIsMTU4OTU0MzkzMzg2N10="
        }
      ],
      "pageInfo": {
        "endCursor": "WyJ6ZWl0LnN3ciIsMTU4OTU0MzkzMzg2N10=",
        "startCursor": "WyJ6ZWl0Lm5leHQuanMiLDE1ODk1NDM5OTc1NTdd",
        "hasNextPage": true
      }
    }
  }
}

结果对象和参数的格式是基于Relay's GraphQL Cursor Connections Specification,它已经成为一个相当普遍的分页规范,并且已经被广泛采用,例如在GitHub's GraphQL API。在结果对象中,我们有一个edges数组,包含有nodecursor属性的项目。正如我们所知,node包含存储库本身。另一方面,cursor是节点的一个Base64编码表示。在这种情况下,它包含版本库的ID和版本库的创建日期作为时间戳。这是我们所需要的信息,当他们按照版本库的创建时间排序时,就可以指向该项目。pageInfo包含诸如数组中第一个和最后一个项目的游标等信息。

假设我们想获得当前集合的最后一个项目之后的下一组项目,即 "zeit/swr "存储库的。我们可以将查询的after参数设置为endCursor的值,像这样。

{
  repositories(first: 2, after: "WyJ6ZWl0LnN3ciIsMTU4OTU0MzkzMzg2N10=") {
    totalCount
    edges {
      node {
        id
        fullName
        createdAt
      }
      cursor
    }
    pageInfo {
      endCursor
      startCursor
      hasNextPage
    }
  }
}

现在我们有了下两个项目,我们可以继续这样做,直到hasNextPage的值为false,意味着我们已经到达了列表的末端。要深入了解基于游标的分页,请阅读Shopify的文章Pagination with Relative Cursors。它提供了关于实现本身和比传统的基于索引的分页更多的细节。

Infinite scrolling

移动和桌面应用中的垂直滚动列表通常使用一种叫做无限滚动的技术实现。无限滚动的原理非常简单。

  • 获取初始项目集

  • 当用户到达最后一个项目时,获取最后一个项目之后的下一组项目

第二步重复进行,直到用户厌倦了滚动或超过了某种滚动限制。无限滚动 "这个名字是指列表似乎是无限的--用户可以一直滚动,新的项目不断出现在列表中。

让我们来看看在实践中如何使用Apollo客户端的useQuery钩。Apollo客户端在实现基于光标的分页方面有一个很好的文档。让我们以审查过的仓库列表为例,实现无限滚动。

首先,我们需要知道用户何时到达了列表的末尾。幸运的是,FlatList组件有一个proponEndReached,一旦用户滚动到列表的最后一项,它将调用所提供的函数。你可以使用onEndReachedThreshold这个prop来改变onEndReach回调的早期调用。改变RepositoryList组件的FlatList组件,使其在达到列表的末端时调用一个函数。

export const RepositoryListContainer = ({
  repositories,
  onEndReach,
  /* ... */,
}) => {
  const repositoryNodes = repositories
    ? repositories.edges.map((edge) => edge.node)
    : [];

  return (
    <FlatList
      data={repositoryNodes}
      // ...
      onEndReached={onEndReach}
      onEndReachedThreshold={0.5}
    />
  );
};

const RepositoryList = () => {
  // ...

  const { repositories } = useRepositories(/* ... */);

  const onEndReach = () => {
    console.log('You have reached the end of the list');
  };

  return (
    <RepositoryListContainer
      repositories={repositories}
      onEndReach={onEndReach}
      // ...
    />
  );
};

export default RepositoryList;

试着滚动到审查过的存储库列表的末尾,你应该在日志中看到这个消息。

接下来,我们需要在列表到达终点时获取更多的软件库。这可以通过useQuery钩子提供的fetchMore函数来实现。为了描述Apollo客户端,如何将缓存中现有的仓库与下一组仓库合并,我们可以使用field policy。一般来说,字段策略可以用readmerge函数来定制读写操作中的缓存行为。

让我们为apolloClient.js文件中的repositories查询添加一个字段策略。

import { ApolloClient, InMemoryCache, createHttpLink } from '@apollo/client';
import { setContext } from '@apollo/client/link/context';
import Constants from 'expo-constants';
import { relayStylePagination } from '@apollo/client/utilities';
const { apolloUri } = Constants.manifest.extra;

const httpLink = createHttpLink({
  uri: apolloUri,
});

const cache = new InMemoryCache({  typePolicies: {    Query: {      fields: {        repositories: relayStylePagination(),      },    },  },});
const createApolloClient = (authStorage) => {
  const authLink = setContext(async (_, { headers }) => {
    try {
      const accessToken = await authStorage.getAccessToken();

      return {
        headers: {
          ...headers,
          authorization: accessToken ? `Bearer ${accessToken}` : '',
        },
      };
    } catch (e) {
      console.log(e);

      return {
        headers,
      };
    }
  });

  return new ApolloClient({
    link: authLink.concat(httpLink),
    cache,  });
};

export default createApolloClient;

如前所述,分页的结果对象和参数的格式是基于Relay的分页规范。幸运的是,Apollo客户端提供了一个预定义的字段策略,relayStylePagination,它可以在这种情况下使用。

接下来,让我们改变useRepositories钩子,使它返回一个装饰过的fetchMore函数,它用适当的参数调用实际的fetchMore函数,这样我们就可以获取下一组存储库了。

const useRepositories = (variables) => {
  const { data, loading, fetchMore, ...result } = useQuery(GET_REPOSITORIES, {
    variables,
    // ...
  });

  const handleFetchMore = () => {
    const canFetchMore = !loading && data?.repositories.pageInfo.hasNextPage;

    if (!canFetchMore) {
      return;
    }

    fetchMore({
      variables: {
        after: data.repositories.pageInfo.endCursor,
        ...variables,
      },
    });
  };

  return {
    repositories: data?.repositories,
    fetchMore: handleFetchMore,
    loading,
    ...result,
  };
};

确保你的pageInfocursor字段在你的repositories查询中,如分页例子中所述。你还需要包括查询的afterfirst参数。

handleFetchMore函数将调用Apollo客户端的fetchMore函数,如果有更多的项目需要获取,这由hasNextPage属性决定。我们还想防止在获取过程中获取更多的项目。在这种情况下,loading将是true。在fetchMore函数中,我们为查询提供一个after变量,它接收最新的endCursor值。

最后一步是在onEndReach处理器中调用fetchMore函数。

const RepositoryList = () => {
  // ...

  const { repositories, fetchMore } = useRepositories({
    first: 8,
    // ...
  });

  const onEndReach = () => {
    fetchMore();
  };

  return (
    <RepositoryListContainer
      repositories={repositories}
      onEndReach={onEndReach}
      // ...
    />
  );
};

export default RepositoryList;

在尝试无限滚动时,使用一个相对较小的first参数值,如8。这样你就不需要审查太多的存储库。你可能会面临这样一个问题:onEndReach处理程序在视图加载后被立即调用。这很可能是因为列表中的存储库太少了,以至于马上就到达了列表的末端。你可以通过增加first参数的值来解决这个问题。一旦你确信无限滚动是有效的,可以随意使用更大的first参数值。

Additional resources

随着我们越来越接近这部分的结束,让我们花点时间看看一些额外的React Native相关资源。Awesome React Native是一个非常全面的React Native资源的策划列表,如库、教程和文章。因为这个列表非常长,让我们仔细看看其中的几个亮点吧

React Native Paper

Paper是React Native可定制和可生产的组件的集合,遵循谷歌的Material Design准则。

React Native Paper对于React Native来说就像Material-UI对于React网络应用一样。它提供了广泛的高质量UI组件,支持自定义主题和相当简单的设置,用于基于世博会的React Native应用。

Styled-components

利用标记的模板字面(最近添加到JavaScript中)和CSS的力量,风格化组件允许你编写实际的CSS代码来风格化你的组件。它还消除了组件和样式之间的映射关系--将组件作为一个低级的样式结构来使用是再简单不过了!

Styled-components是一个使用CSS-in-JS技术为React组件设计样式的库。在React Native中,我们已经习惯于将组件的样式定义为一个JavaScript对象,所以CSS-in-JS并不是一个未知的领域。然而,styled-components的方法与使用StyleSheet.create方法和styleprop有很大不同。

在styled-components中,组件的样式是通过使用一个叫做标签模板字面的功能或一个普通的JavaScript对象与组件一起定义。风格化组件使得基于组件的prop在运行时为组件定义新的风格属性成为可能。这带来了许多可能性,比如在浅色和深色主题之间无缝切换。它也有一个完整的主题支持。下面是一个创建Text组件的例子,该组件具有基于props的风格变化。

import styled from 'styled-components/native';
import { css } from 'styled-components';

const FancyText = styled.Text`
  color: grey;
  font-size: 14px;

  ${({ isBlue }) =>
    isBlue &&
    css`
      color: blue;
    `}

  ${({ isBig }) =>
    isBig &&
    css`
      font-size: 24px;
      font-weight: 700;
    `}
`;

const Main = () => {
  return (
    <>
      <FancyText>Simple text</FancyText>
      <FancyText isBlue>Blue text</FancyText>
      <FancyText isBig>Big text</FancyText>
      <FancyText isBig isBlue>
        Big blue text
      </FancyText>
    </>
  );
};

因为styled-components处理的是样式定义,所以可以在属性名和属性值中使用类似CSS的蛇形句法。然而,单位没有任何影响,因为属性值在内部是没有单位的。关于styled-components的更多信息,请前往文档

React-spring

react-spring是一个基于弹簧物理学的动画库,应该能满足你大部分的UI相关动画需求。它为你提供了足够灵活的工具,可以自信地将你的想法投射到移动界面中。

React-spring是一个库,为React Native组件的动画化提供了一个干净的API

React Navigation

为你的React Native应用提供路由和导航

React Navigation 是一个React Native的路由库。它与我们在本章节和前面部分使用的React Router库有一些相似之处。然而,与React Router不同的是,React Navigation提供了更多的本地功能,如本地手势和动画在视图之间的转换。

Closing words

就这样,我们的应用已经准备好了。干得好!在我们的旅程中,我们学到了许多新的概念,如使用Expo设置我们的React Native应用,使用React Native's核心组件并为其添加样式,与服务器通信,以及测试React Native应用。最后一块拼图将是把应用部署到苹果应用商店和Google Play商店。

部署应用完全是可选的,而且这也不是很琐碎,因为你还需要分叉和部署rate-repository-api。对于React Native应用本身,你首先需要按照Expo's document创建iOS或Android构建。然后你可以把这些构建上传到苹果应用商店或谷歌应用商店。Expo也有这方面的文档