去年底在公司分享了關於使用 Jest 進行前端單元測試的議題,簡報版本在此,現在將其整理為文章以方便日後查詢與閱讀。

前言

從事前端工作也幾年了,先前有諸多原因都未能在公司產品上實行單元測試,例如做功能都來不及了,哪有時間寫測試、程式寫的太過耦合難以測試之類的。

我以前只在個人的 side project 上寫過單元測試,也的確感受到「測試能夠減低重構時不致無意間把原有功能搞壞就釋出的風險」帶來的好處。而這次算是我第一次在公司產品上導入單元測試,在測試的領域還算是新手,這篇文章便作為新手的個人筆記,有什麼錯誤的部份請偷偷告訴我 🙂

再提一下情境:我們使用 React + Redux 進行 SPA (Single Page Application) 的前端網站開發,是前後端分離的開發模式,後端團隊出 JSON 格式的 API 給前端進行資料串接。在 Scrum 會議中 Story Refinement Meeting 開 Story 的 Sub tasks 都會特別開 Unit Test 實作的票。

Why Jest?

為什麼選擇 Jest 呢?跟最常拿來被比較的 Mocha 來說,從 Jasmine 發展而來的 Jest 本身對於 React 的整合度挺不錯的(而且是 Facebook 自家的,釋出與修正速度可靠許多),而且本身也整合好了斷言 (Assertion)、Mock 等 Library 以及 Coverage 報告功能,還有滿特別的 Snapshot 測試,以及優秀的 watch 模式可以只測試當次更改的檔案,最重要是它的設定頗好上手的。

Jest 在舊版時的 Auto mocking 有點難用,當時在做個人 side project 也曾經在 contributors 建議下在 Mocha 跟 Jest 之間轉換過一兩次。

至於執行速度倒是有各家說法,我沒有實際去比較過各 frameworks 的速度,但我知道 Jest 跟新興的 AVA 都能平行跑多個測試,相信表現也不差。

Mocha 也有它適合使用的場景,例如執行環境是瀏覽器的時候。

Jest 基本設定

首先要先安裝 Jest 本身及其相關套件:

yarn add jest@^19.0.2 babel-jest@^19.0.0 react-test-renderer@^15.4.2 jasmine-reporters@^2.2.0 identity-obj-proxy@^3.0.0 redux-mock-store@^1.2.0 nock@^9.0.9 --dev

其中 jasmine-reporters 是為了產生給 CI 用的 JUnitXmlReporter 格式之用;identity-obj-proxy 是為了 mock sass、css 檔案;而 redux-mock-store 就是為了 mock redux 的 store;nock 可以 mock 掉 HTTP requests。

安裝完畢後,先準備幾個檔案:

接著在 package.json 中新增 jest 的設定區塊:

"jest": {
    "collectCoverageFrom": [
      "src/**/*.js",
      "!**/__mocks__/**",
      "!**/__tests__/**"
    ],
    "setupFiles": [
      "<rootDir>/config/jest/setup.js"
    ],
    "setupTestFrameworkScriptFile": "<rootDir>/config/jest/setupTestFramework.js",
    "testPathIgnorePatterns": [
      "<rootDir>[/\\\\](build|docs|node_modules)[/\\\\]"
    ],
    "testURL": "http://localhost",
    "transform": {
      "^.+\\.(js|jsx)$": "<rootDir>/config/jest/transform.js",
      "^.+\\.(scss|css)$": "<rootDir>/config/jest/cssTransform.js",
      "^(?!.*\\.(js|jsx|css|scss|json)$)": "<rootDir>/config/jest/fileTransform.js"
    },
    "transformIgnorePatterns": [
      "[/\\\\]node_modules[/\\\\].+\\.(js|jsx)$"
    ],
    "testRegex": "/__tests__/.*\\.(test|spec)\\.js$"
  }

以下將分段解釋各區塊的用途,當然也可以直接閱讀官網說明文件。

"collectCoverageFrom": [
      "src/**/*.js",
      "!**/__mocks__/**",
      "!**/__tests__/**"
    ],

是計算 coverage 的檔案範圍,前面加驚嘆號便是排除的規則。


"setupFiles": [
      "<rootDir>/config/jest/setup.js"
    ],

是在每個測試之前都會跑的 script 設定,可設定多組,而且它們將跑在 setupTestFrameworkScriptFile 之前。本例的 setup.js 檔案內容是:

const localStorageMock = {
  getItem: jest.fn(),
  setItem: jest.fn(),
  clear: jest.fn(),
};

global.localStorage = localStorageMock;
global.__CLIENT__ = true;
global.__DEVELOPMENT__ = false;

大致上是 mock 掉 localStorage 的方法跟環境變數值。


"setupTestFrameworkScriptFile": "<rootDir>/config/jest/setupTestFramework.js",

在每個測試之前都會跑這段的設定,而且它在 setupFiles 設定之後。通常是作為動態複寫掉 jasmine 的 API 之用,以本例而言,setupTestFramework.js 內容為:

if (process.env.CI) {
  const jasmineReporters = require('jasmine-reporters');
  const junitReporter = new jasmineReporters.JUnitXmlReporter({
    savePath: 'testresults',
    consolidateAll: false,
  });

  jasmine.getEnv().addReporter(junitReporter);
}

是在 CI 環境中設定 jasmine reporter 的參數。


"testPathIgnorePatterns": [
      "<rootDir>[/\\\\](build|docs|node_modules)[/\\\\]"
    ],

testPathIgnorePatterns 顧名思義,就是排除在之外的測試檔案規則,以本例而言就排除掉 node_modules 任何子目錄中的測試檔案。(我們沒理由去測經由 npm 或 yarn 安裝到 node_modules 中的 packages)


"testURL": "http://localhost",

testURL 設定在 jsdom 環境中的網址,它同時也反映在 location.href 上。


"transform": {
      "^.+\\.(js|jsx)$": "<rootDir>/config/jest/transform.js",
      "^.+\\.(scss|css)$": "<rootDir>/config/jest/cssTransform.js",
      "^(?!.*\\.(js|jsx|css|scss|json)$)": "<rootDir>/config/jest/fileTransform.js"
    },
    "transformIgnorePatterns": [
      "[/\\\\]node_modules[/\\\\].+\\.(js|jsx)$"
    ],

這兩段一起講:transformIgnorePatterns 就是排除在 transform 規則之外的檔案規則。

而 transform 則是一組「要如何在 Jest 環境中轉換檔案的設定」,例如我們想測試 TypeScript 的檔案,但 Jest 原生並不支援,就可在此設定 transformer 將之編譯成 JavaScript。

而這邊做了 3 種轉換器設定,第一組處理 .js/.jsx 檔案的 transform.js:

const babelJest = require('babel-jest');

module.exports = babelJest.createTransformer();

使其透過 babel 轉換。

第二組處理 .scss/.css 檔案的 cssTransform.js:

// This is a custom Jest transformer turning style imports into empty objects.
// http://facebook.github.io/jest/docs/tutorial-webpack.html

module.exports = {
  process() {
    return `
      const idObj = require('identity-obj-proxy');
      module.exports = idObj;
    `;
  },
  getCacheKey(fileData, filename) { // eslint-disable-line no-unused-vars
    // The output is always the same.
    return 'cssTransform';
  },
};

把 style mock 掉,遇到這類檔案便回傳相應的 key,這樣我們就可以測試 classname 是否相符。


最後是處理 .js/.jsx/.css/.scss/.json 之外的檔案類型的 fileTransform.js:

const path = require('path');

// This is a custom Jest transformer turning file imports into filenames.
// http://facebook.github.io/jest/docs/tutorial-webpack.html

module.exports = {
  process(src, filename) {
    // eslint-disable-next-line prefer-template
    return 'module.exports = ' + JSON.stringify(path.basename(filename)) + ';';
  },
};

透過轉換器可以取回相應的檔案名稱。


最後是 testRegex,讓我們設定測試腳本檔案本身的規則:

"testRegex": "/__tests__/.*\\.(test|spec)\\.js$"

例如在此我們就是得放在 __tests__ 目錄底下,並以 .test.js 或 .spec.js 檔案名稱作為結尾。

透過 Enzyme 使用如 jQuery 般好用的元素選擇器

Enzyme 是由 Airbnb 開發的開放原始碼專案,它建構在 React 原生的 ReactTestUtils 之上,目的是讓開發者更容易撰寫測試,它提供類似 jQuery 的選擇器語法,比起官方原生的寫法簡短許多。

例如原生的寫法是這樣:

ReactTestUtils.findRenderedDOMComponentWithClass('.abc')

但換成 Enzyme 的話,只要這樣:

subject.find('.abc')

相當簡潔方便!

而測試 React Component 時免不了要 render 它才能進行測試,而 Enzyme 提供三種 render API 的方式:

  • shallow
    • 封裝原生的 shallow rendering
    • 只 render 第一層(Component 本身),速度較快,無法測試完整的生命週期
  • render
    • 類似 shallow,但使用 Cheerio,也就不是 React Virtual DOM,而是真的瀏覽器中的 HTML DOM 了。
  • mount
    • Full rendering
    • 能夠測試完整的生命週期
    • 能夠測試與 DOM 的互動與後續產生的變化,例如 Click 後改變 state,進而改變 DOM。

語法及規則

describe 與 it(test)

官網文件。

這是撰寫單元測試的語法架構,主要由 describe 跟 it(在這邊又可以用 test 代替)組成。

it 是測試最小單位,裡面請只做一件測試案例(test case),確保要測試的這件事情是單一的。第一個參數是測試名稱,我們通常會寫的像「一句話」,例如 it('has lemon in', ...)、it('did not rain', ...)。

describe 則是把相關的測試案例都整理在一起,其下又可以包一層 describe 作更進一步的分類整理。還可以搭配 before、beforeEach、after、afterEach 作更多「每次測試案例之前之後,要作什麼動作」等更多不同的變化用法。

還可以透過在字首加上 f 或 x 以「只測它」或「跳過它」。在新版 Jest 中它們被定義成更明確語意的 .only 跟 .skip 了。

expect

斷言(Assertion)就是我們要驗證的事情本身。

例如 期望 A 是一顆蘋果:expect(A).toBe('Apple')。如果斷言的期望結果與執行結果不一致,那代表這個測試案例會失敗。

相關語法直接參考 官網文件 吧。

我們使用的命名規則

前面提到我們的專案是 React + Redux,而 Redux 的部份我們使用 Ducks: Redux Reducer Bundles。

測試腳本檔案都放在與「欲測試的 source」同一層的 __tests__ 目錄底下,檔名叫做 {source 檔名}.test.js。例如目錄結構會像這樣:

components/
  Header/
    __tests__/
      Header.test.js
    Header.js

而 redux 相關的檔案我們使用了 Ducks Pattern 去寫,因此 source 都是在同一支檔案中,相關的測試檔案命名會是將 actions 的測試叫做 {source 檔名}.actions.test.js、reducer 的測試則叫做 {source 檔名}.reducers.test.js,實例:

redux/
  modules/
    __tests__/
      albums.actions.test.js
      albums.reducers.test.js
    albums.js

另外,如果測試案例中需要定義一些假資料,過長的內容不建議直接放在測試腳本中,而是新建另外一支檔案放置,目錄位置與測試案例同一層,命名為 mocks/mock{首字大寫的 source 檔案名稱}.json,例如:

redux/
  modules/
    __tests__/
      mocks/
        mockAlbum.json
      albums.actions.test.js
      albums.reducers.test.js
    albums.js

我們專案中的測試重點及方法

這是現行專案的測試撰寫重點參考,目前也有在評估使用 wix 的 redux-testkit。

Component

  1. Snapshot 測試(Jest v14 出現的神奇玩意)
    • 比對產生的 HTML 結果是否與上次 snapshot 相符(expect(xxx).toMatchSnapshot())
  2. 模擬行為測試,例如點擊行為
    • simulate
    • 可以搭配 toBeCalledWith 偵測是否觸發了某個 function,並是隨著哪些值被觸發的
import React from 'react';
import { mount } from 'enzyme';

import mockResponse from './mocks/mockResponse';

describe('<Albums />', function test() {
  beforeEach(() => {
    jest.resetModules();
    jest.resetAllMocks();

    const Albums = require('../Albums');
    const { data } = mockResponse.items;

    this.params = {
      list: data,
      load: jest.fn(),
    };
    this.makeSubject = (params = this.params) => {
      return mount(<Albums {...params} />);
    };
  });

  describe('Albums', () => {
    let subject;

    beforeEach(() => {
      subject = this.makeSubject();
    });

    it('should render', () => {
      expect(subject.length).toBeTruthy();
    });

    it('should render as snapshot', () => {
      expect(subject.html()).toMatchSnapshot();
    });
  });

  describe('when click item', () => {
    let subject;

    it('performance tab should trigger load', () => {
      subject = this.makeSubject();
      const item = subject.find('.specialItem');

      item.simulate('click');
      expect(this.params.load).toBeCalled();
    });
  });
});

Container

  1. Container 與 Component 都有被 render 出來
    • 需要用 <Provider> 包裹並搭配 mock store
  2. 被 mock 的 component 有被 call
  3. 執行相關的 actions 有觸發 dispatch
jest.mock('../Search');
jest.mock('../../../../redux/modules/search', () => {
  return {
    load: keyword => keyword,
  };
});

import React from 'react';
import { Provider } from 'react-redux';
import { mount } from 'enzyme';
import SearchContainer from '../SearchContainer';
import Search from '../Search';

const storeFake = (state) => {
  return {
    default: () => {},
    subscribe: () => {},
    dispatch: jest.fn(),
    getState: () => {
      return { ...state };
    },
  };
};

describe('<SearchContainer />', function() {
  let subject;
  let Component;
  let SearchComponent;

  beforeEach(() => {
    jest.resetModules();
    jest.resetAllMocks();
    const store = storeFake({});

    subject = mount(
      <Provider store={store}>
        <SearchContainer />
      </Provider>
    );

    Component = subject.find(SearchContainer);
    SearchComponent = Component.find(Search);
  });

  it('component should be called exactly once', () => {
    expect(Search.mock.calls.length).toBe(1);
  });

  it('should render', () => {
    expect(Component.length).toBeTruthy();
    expect(SearchComponent.length).toBeTruthy();
  });

  it('should execute the dispatch function with `淡水` after calling the load function with `淡水`', () => {
    SearchComponent.props().load('淡水');
    expect(subject.instance().store.dispatch).toBeCalledWith('淡水');
  });
});

Reducer

  1. 輸入一次 initial state 與 action 後,返回預期的 state
  2. 連續輸入 initial state 與 actions 後,與 snapshot 相符
describe('todoReducer', () => {
  let todoReducer;
  let todo;
  let initialState;

  beforeEach(() => {
    jest.resetModules();
    jest.resetAllMocks();
    todoReducer = require('../todo').default;
    todo = require('../todo');

    initialState = {
      todo: [],
    };
  });

  describe('add new todo', () => {
    it('should add new todo', () => {
      const result = todoReducer(initialState, todo.add('buy ticket'));

      expect(result).toEqual({
        todo: ['buy ticket'],
      });
    });
  });

  describe('not exist type', () => {
    it('should show original state', () => {
      const result = todoReducer(initialState, 'NOT_EXIST');

      expect(result).toEqual(initialState);
    });
  });

  it('should match snapshot', () => {
    const uiActions = [
      { type: 'ADD_TODO', payloads: 'go to supermarket' },
      { type: 'ADD_TODO', payloads: 'send email' },
    ];

    let state;

    for (const action of uiActions) {
      state = todoReducer(state, action);
      expect(state).toMatchSnapshot();
    }
  });
});

Action

  1. 執行 action 後,返回預期的物件(例如 { type: XXX, payloads: 123 })
  2. 承上,但中間經過 mock 過的 http request(透過 nock 設定成功與失敗的 response)
import nock from 'nock';
import configureMockStore from 'redux-mock-store';
import mockTodo from './mocks/mockTodo.json';

describe('todo actions', () => {
  let todo;
  let middlewares = [];  // ...略
  let mockStore = configureMockStore(middlewares);
  let store;
  let scope;
  const apiBaseUrl = 'http://localhost';

  let TODO_LOAD;
  let TODO_LOAD_SUCCESS;
  let TODO_LOAD_FAIL;

  beforeEach(() => {
    jest.resetModules();
    jest.resetAllMocks();
    nock.cleanAll();
    scope = null;

    todo = require('../todo'); // eslint-disable-line global-require

    store = mockStore({
      todo: [],
    });

    TODO_LOAD = 'patw/todo/TODO_LOAD';
    TODO_LOAD_SUCCESS = 'patw/todo/TODO_LOAD_SUCCESS';
    TODO_LOAD_FAIL = 'patw/todo/TODO_LOAD_FAIL';
  });

  describe('action: load - load & success', () => {
    let expectedBody;
    let expectedActions;

    beforeEach(() => {
      expectedBody = {
        items: mockTodo.items,
      };
      expectedActions = [
        {
          type: TODO_LOAD,
        },
        {
          result: {
            items: {
              data: mockTodo.items.data,
            },
          },
          type: TODO_LOAD_SUCCESS,
        },
      ];

      scope = nock(apiBaseUrl)
        .get(`/api/todos`)
        .reply(200, {
          items: {
            data: expectedBody.items.data,
          },
        });
    });

    it('should be thenable function', async () => {
      const result = store.dispatch(todo.load());

      await result;

      expect(typeof result.then).toBe('function');
      expect(scope.isDone()).toBeTruthy();
    });

    it('should show load & success actions', async () => {
      await store.dispatch(todo.load());

      expect(store.getActions()).toEqual(expectedActions);
      expect(scope.isDone()).toBeTruthy();
    });

    it('should get expected data', async () => {
      const result = await store.dispatch(todo.load());

      expect(result.data).toEqual(expectedActions[1].result);
      expect(scope.isDone()).toBeTruthy();
    });
  });

  describe('action: load - load & fail', () => {
    let expectedActions;
    const errorMessage = 'Internal Server Error';

    beforeEach(() => {
      expectedActions = [
        {
          type: TODO_LOAD,
        },
        {
          error: {
            body: 'Internal Server Error',
          },
          type: TODO_LOAD_FAIL,
        },
      ];

      scope = nock(apiBaseUrl)
        .get(`/api/todos`)
        .reply(500, {
          body: errorMessage,
        });
    });

    it('should show load & fail actions', () => {
      return store.dispatch(todo.load())
        .catch(() => {
          expect(store.getActions()).toEqual(expectedActions);
          expect(scope.isDone()).toBeTruthy();
        });
    });

    it('should get exception', async () => {
      try {
        await store.dispatch(todo.load());
      } catch (object) {
        expect(object.response.data.body).toEqual(errorMessage);
        expect(scope.isDone()).toBeTruthy();
      }
    });
  });
});

Reselect

  1. 計算結果與預期相符
  2. 輸入相同的值,不會重新計算(recomputation)
describe('ui selector test', () => {
  let ui;

  beforeEach(() => {
    jest.resetModules();
    jest.resetAllMocks();
    ui = require('../ui'); // eslint-disable-line global-require
  });

  it('uiHeaderSelector', () => {
    const state = { ui: { hideHeader: true } };

    expect(ui.uiHeaderSelector(state)).toBeTruthy();
  });
});

Router

  1. params/path 相符
  2. property 相符
  3. component 相符
import { match } from 'react-router';

jest.mock('../containers/App/App');
jest.mock('../components/Project/Project');

describe('routes testing', () => {
  let getRoutes;

  beforeEach(() => {
    jest.resetModules();
    jest.resetAllMocks();
    getRoutes = require('../routes').default;
  });

  describe('/projects/:id', () => {
    const expectedId = 9527;
    const location = `/projects/${expectedId}`;
    let App;
    let Project;

    beforeEach(() => {
      App = require('../containers/App/App').default;
      Project = require('../components/Project/Project').default;
    });

    it('should be correct id in router params', () => {
      match({
        routes: getRoutes(),
        location,
      }, (error, redirectLocation, renderProps) => {
        expect(renderProps.params.id).toBe(expectedId);
      });
    });

    it('should render correct components', () => {
      match({
        routes: getRoutes(),
        location,
      }, (error, redirectLocation, renderProps) => {
        expect(renderProps.routes[1].component).toEqual(App);
        expect(renderProps.routes[2].component).toEqual(Project);
      });
    });
  });

  // Auto redirect
  describe('/', () => {
    const location = '/';

    it('should have REPLACE action and correct pathname', () => {
      match({
        routes: getRoutes(),
        location,
      }, (error, redirectLocation) => {
        expect(redirectLocation.action).toEqual('REPLACE');
        expect(redirectLocation.pathname).toEqual('/projects/9527');
      });
    });
  });

  describe('NotFound page', () => {
    let App;
    let NotFound;

    beforeEach(() => {
      App = require('../containers/App/App').default;
      NotFound = require('../containers/NotFound/NotFound').default;
    });

    [
      '/notfound',
      '/it-should-not-be-existed',
    ].forEach((location) => {
      describe(`on ${location}`, () => {
        it('should render correct components', () => {
          match({
            routes: getRoutes(),
            location,
          }, (error, redirectLocation, renderProps) => {
            expect(renderProps.routes[1].component).toEqual(App);
            expect(renderProps.routes[2].component).toEqual(NotFound);
          });
        });
      });
    });
  });
});

其他小技巧

jest.mock()

  • Jest 的特色之一,讓測試專注在現在測試的模組本身,mock 掉 import 進來的東西(例如子 component)。
  • Jest v15 起,auto mocking 是預設關閉的。
  • 透過 mock 可以自訂 import 模組中某個 function 返回的東西。
// foo.js
module.exports = function() {
  // some implementation;
};

// test.js
jest.mock('../foo', () => {
  return jest.fn(() => 42);
});
const foo = require('../foo');

// foo is a mock function
foo();
// 42

jest.resetModules()

確保 import/require 的 module 是初始乾淨未被污染的狀況。

通常放在 beforeEach 中。

jest.resetAllMocks()

重設所有 mock 物件的狀態。

jest.fn()

把第三方或傳入的 function mock 起來,如此僅要測試被 call 的次數或是附帶傳入的參數是否正確即可。

global.ga = jest.fn();
global._paq = {
  push: jest.fn(),
};

例如在程式碼中呼叫了 Google Analytics (ga) 或 piwik (paq) 的程式碼,但測試時沒有必要載入 ga.js,那麼就透過 jest.fn() mock 掉它就好。

此外,可以測試該被 mock 過的 function 是否被呼叫(toBeCalled())、被呼叫時附帶了什麼參數(toBeCalledWith(params))。在 beforeEach 中,用 mockFn.mockClear() 確保每次都重置狀態(傳入參數、次數)。

timer 相關 mock

在測試中要處理 setTimeout、setInterval、clearTimeout、clearInterval 有些麻煩,因為面對的是真實的時間流,還好 Jest 也提供了 mock timer 的方法,讓你可以掌控時間,太神了!

執行 jest.useFakeTimers() 以啟用 fake timer,如此一來所有的 timer function 都會變成 mock function 了。

亦可透過 jest.runAllTimers(); 快進功能,幫忙快速跑過所有的時間等待。

詳情可見官方文件。

Async Test

可以透過安裝 babel-jest 套件來支援 async/await 的語法,讓我們可以更簡潔地處理非同步的測試。例如:

it('works with async/await', async () => {
  await expect(user.getUserName(4)).resolves.toEqual('Mark');
});

至於錯誤處理的話可以這樣寫:

it('tests error with async/await', async () => {
  await expect(user.getUserName(3)).rejects.toEqual({
    error: 'User with 3 not found.',
  });
});

Coverage

Jest 已經幫我們內建了計算\產生 coverage 的機制,只要在執行 jest 時加上 --coverage 參數即可。

以在本篇 Jest 基本設定 一節的設定而言,我們只要打:

yarn run test -- --coverage

執行完就能看到 Coverage 分析報告了,同時它也會產生 HTML 版的報告哦。

image

此外,如果整合進 CI 流程中,我們串接的是 Codecov 這套服務;在 CI 中跑完測試後,可以設定將 coverage 狀態回覆在 Pull request 中。

image

結語

簡單的介紹就到這邊,對於測試的認識不深,獻醜了 XD 有更好的作法可以一起討論哦!

雖然才剛開始一段時間,不過在前端專案中導入測試後,我本身的確是有感到有幫助的,特別是在重構之時,也增加了重構時的信心。而在寫 Component 時,自然也會盡量想將 Component 的職責單一化,以更易於撰寫測試。(對,我們不是 TDD)

另外要導入也要看專案團隊的合作模式,倒不是一定要跑 Scrum 才能導入,而是要整個團隊的理解跟配合(Scrum 也不是萬靈丹啊~),例如專案經理(或是管理層)是否能夠了解撰寫測試帶來的好處,將之視為一項投資、開發團隊亦要認同,不然即便成為一項佔用開發估時的工作,最後也不一定能與期望結果相符;此外專案本身的實作週期若是太短,一切都是趕上線為第一優先(例如接案公司),那可能要玩到測試就挺難的了 XD

接下來有空會再分享「撰寫 E2E 自動化測試」的心得(雖然是以手機 App 為測試目標啦),下次見!

參考連結