前端单测学习(10) —— 状态管理redux

795 阅读7分钟

前言

在往期的文章中我们对react component、hook等相关的单测场景进行了学习,今天这篇是针对应用状态管理进行单测的学习,在react项目开发中,提供状态管理有多种方式,比如在单个组件中的state,FC就是用的useState,class component使用的setState,对于一些全局或者局部的状态管理,还可以通过context的方式进行管理。今天我们要进行单测学习的不是上面提到的这些,我们这里找一些第三方的状态管理库来进行学习。

why redux

笔者在早期是用过redux,不过近两年用的是modernjs的reduck,reduck也是有提供对应的单测方法,这里感兴趣的同学可以去了解下。考虑到市面上估计比较多是使用的reduk,所以这一期就还是选择reduk来作为我们状态管理的单测对象。早期笔者使用redux被各种概念搞的有点懵,而且需要配合各种库,明显对新手不友好,使用起来也是挺多不方便的地方。最近看了一下,看到了redux toolkit,这个toolkit的出现极大的方便了我们上手redux,这里也是安利大家可以去尝试下。

补充redux

我们还是在原先的项目中来做,第一步是需要补充redux需要的包,我们在根目录下安装这两个包

pnpm install @reduxjs/toolkit
pnpm install react-redux

我们创建一个目录来放置状态相关的文件

mkdir store
cd store
touch index.ts

我们简单写一个计数器相关的状态管理,在全局维护这个当前计数的状态,然后提供更新这个计数的一些方法

mkdir slice
cd slice
touch counter.ts

counter.ts我们补充计数器相关的状态和action,这里我们直接使用@reduxjs/tooltik提供的方法,就不需要再像之前那样进行繁琐的定义声明等等。具体可以看下下面的代码

import { createSlice, PayloadAction } from "@reduxjs/toolkit";

interface CounterState {
  value: number;
}

const initialState: CounterState = {
  value: 0
}


export const CounterSlice = createSlice({
  name: 'Counter',
  initialState,
  reducers: {
    increment: state => {
      state.value += 1;
    },
    decrement: state => {
      state.value -= 1;
    },
    incrementByMount: (state, action: PayloadAction<number>) => {
      state.value += action.payload;
    },
    decrementByMount: (state, action: PayloadAction<number>) => {
      state.value -= action.payload;
    }
  }
});

export const CounterAction = CounterSlice.actions;

export const CounterReducer = CounterSlice.reducer;

我们简单走读一下,用上了@reduxjs/tooltik之后,我们的状态管理代码变得简洁很多,第一步是定义了状态的类型和初始化的状态,就是initialStatereducers中声明更新状态的各种方法。最后就是将需要暴露出去的方法export出去。
store/index.tsx中组合所有的状态

import { configureStore } from "@reduxjs/toolkit";
import { CounterReducer } from "./slice/counter";

export const store = configureStore({
  reducer: {
    counter: CounterReducer
  },
});

export type AppState = ReturnType<typeof store.getState>;

export type AppDispatch = typeof store.dispatch;

在我们的入口文件App.tsx我们全局注入store,这样我们就可以在应用中的各个地方直接使用这个状态及对应的action

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

const App = () => {
  return (
    <Provider store={store}>
      <AppContent />
    </Provider>
  );
};

到这一步我们的初步工作就完成了

reducer

reduer是纯函数,操作原先的状态之后放回一个新的状态,我们对于reducer具体的细节不需要过多的关注,只要关注状态的input和output是否符合预期即可,这个也是我们编写单测的重点,中间的实现视为黑盒,只关注input的值能否对应准确的output即可。 我们还是在store目录下建一个__tests__目录存放对应的单测,然后编写对应Counter的单测文件 counter.test.ts

import {} from "@testing-library/react";
import { CounterReducer, CounterAction } from '../slice/counter';

describe('测试counter state', () => {
  it('正确返回初始值', () => {
    expect(CounterReducer(undefined, {}).value).toEqual(0);
  });

  it('正确返回设置初始值', () => {
    expect(CounterReducer({ value: 10 }, {}).value).toEqual(10);
  });


  it('正确响应increment方法', () => {
    expect(CounterReducer({ value: 10 }, CounterAction.increment()).value).toEqual(11);
  });

  it('正确响应decrement', () => {
    expect(CounterReducer({ value: 10 }, CounterAction.decrement()).value).toEqual(9);
  });

  it('正确响应incrementByMount方法', () => {
    expect(CounterReducer({ value: 10 }, CounterAction.incrementByMount(10)).value).toEqual(20);
  });

  it('正确响应decrementByMount方法', () => {
    expect(CounterReducer({ value: 10 }, CounterAction.decrementByMount(10)).value).toEqual(0);
  });

});

简单走读一下我们编写的这个单测,第一个和第二个断言我们是断言了状态的初始值,如果没有设置初始值的情况下则用默认的初始值,如果传入了指定的初始值的情况下我们状态的初始值也是应该和我们设置的保持一致。后面四个断言是针对状态的action做的断言,运行指定的action之后我们的状态会更新到我们预期的值,保证我们action更新状态的正确性。
我们运行一下这个单测,因为不需要执行到其他的单测文件,所以我们这里只运行这个新编写出来的单测即可

pnpm test -- src/store/__tests__/counter.test.ts 

image.png 可以看到我们的测试用例都全部通过,这时候我们的计数器状态管理代码也是保证没有问题。

组件和状态结合的单测

我们的状态管理很多情况下是需要应用到我们的页面中,在react中基本上都是组件,所以肯定会有组件和状态结合的单测,我们这里就针对这种情况编写我们的单测

在页面中消费计数器状态,我们新建一个页面,在页面中我们使用计数器的状态及调用对应的action进行更新

import { Button, InputNumber } from "antd";
import { useCallback, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { AppState } from "../../store";
import { CounterAction } from "../../store/slice/counter";

export default function Counter() {
  const count = useSelector((state: AppState) => state.counter);
  const dispatch = useDispatch();
  const [amount, setAmount] = useState<number>(0);

  const addOne = useCallback(() => {
    dispatch(CounterAction.increment());
  }, [dispatch]);

  const reduceOne = useCallback(() => {
    dispatch(CounterAction.decrement());
  }, [dispatch]);

  const add = useCallback(() => {
    dispatch(CounterAction.incrementByMount(amount));
  }, [dispatch, amount]);

  const reduce = useCallback(() => {
    dispatch(CounterAction.decrementByMount(amount));
  }, [dispatch, amount]);

  return (
    <div>
      <Button type="primary" onClick={addOne} data-testid="addOne">
        加一
      </Button>
      <Button type="primary" onClick={reduceOne} data-testid="reduceOne">
        减一
      </Button>
      <InputNumber
        style={{ width: 200 }}
        value={amount}
        onChange={value => setAmount(value)}
      />
      <Button type="primary" onClick={add} data-testid="addResult">
        加结果
      </Button>
      <Button type="primary" onClick={reduce} data-testid="reductResult">
        减结果
      </Button>
      <span>结果:</span>
      <span data-testid="result">{count.value}</span>
    </div>
  );
}

新写了一个页面作为demo,我们运行一下看下效果

image.png

这边页面实际上就是提供了计数器相关的一些操作按钮和结果的展示,作为一个demo方便我们进行单测的编写

setupJest.ts增加mock,这个详细可以看这个,不然运行的话会有报错

// see: https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom
Object.defineProperty(window, 'matchMedia', {
  writable: true,
  value: jest.fn().mockImplementation(query => ({
    matches: false,
    media: query,
    onchange: null,
    addListener: jest.fn(), // deprecated
    removeListener: jest.fn(), // deprecated
    addEventListener: jest.fn(),
    removeEventListener: jest.fn(),
    dispatchEvent: jest.fn(),
  })),
});

准备工作做好了之后我们就可以开始编写我们的单测文件了,同样是在组件或者页面的当前目录下新建一个__tests__目录,用于放置我们使用状态的组件对应的单测
counter.test.ts补充对应的单测

import {
  act,
  fireEvent,
  render,
  RenderResult,
  screen,
} from "@testing-library/react";
import { Provider } from "react-redux";
import { store } from "../../store";

import Counter from "../Counter";

const renderBook = (): RenderResult =>
  render(
    <Provider store={store}>
      <Counter />
    </Provider>
  );

describe("counter页面单测", () => {
  it("正确响应加一的状态更新", async () => {
    renderBook();
    await act(async () => {
      fireEvent.click(screen.getByTestId("addOne"));
    });
    const resultElement = screen.getByTestId("result");
    expect(resultElement.innerHTML).toEqual("1");
  });

  it("正确响应减一的状态更新", async () => {
    renderBook();
    await act(async () => {
      fireEvent.click(screen.getByTestId("reduceOne"));
    });
    const resultElement = screen.getByTestId("result");
    expect(resultElement.innerHTML).toEqual("0");
  });
});

简单走读一下我们的这个单测文件,我们是针对这个组件做的单测,在整个应用中我们是在最顶层注入了store,我们单测是针对最小单位的组件的话,我们就得自己提供注入这个store,模拟这个环境作为单测的环境。后续的就是对一些按钮的操作然后断言各种变更即可,这些在我们在之前的博客中已经写过不少了,这里就只做简单的实例即可。
我们来运行一些这个单测看下结果:

pnpm test -- src/pages/__tests__/counter.test.tsx

image.png

可以看到我们的用例已经全部通过,符合我们的预期

结尾

本篇文章针对状态管理相关的单测做了简单的介绍,主要是使用了redux这个状态管理库,笔者在之前的项目中redux用的不多,而且这方面的单测也是第一次写,有不妥之处还望各位同学雅正。

传送门

前端单测学习(1)—— 单测入门之react单测项目初步
前端单测学习(2)—— react 组件单测初步
前端单测学习(3)—— react组件单测进阶
前端单测学习(4)—— react 组件方法&fireEvent
前端单测学习(5)—— 快照
前端单测学习(6)—— 定时器
前端单测学习(7)—— mock
前端单测学习(8)—— react hook
前端单测学习(9)—— 覆盖率报告
前端单测学习(10)—— 状态管理redux
前端单测学习(11)—— react hook 进阶
前端单测学习(12)—— 性能优化
前端单测学习(13)—— 自动化测试

代码仓库:github.com/liyixun/rea…
对应分支:feat-redux