Help us learn about your current experience with the documentation. Take the survey.

前端测试标准与风格指南

在 GitLab 开发前端代码时会遇到两种类型的测试套件。我们使用 Jest 进行 JavaScript 单元和集成测试,使用带有 Capybara 的 RSpec 特性测试进行 E2E(端到端)集成测试。

所有新功能都需要编写单元和特性测试。大多数情况下,你应该使用 RSpec 编写特性测试。有关如何开始编写特性测试的更多信息,请参见 开始编写特性测试

对于修复 Bug,应编写回归测试以防止其未来再次出现。

有关 GitLab 一般测试实践的更多信息,请参阅 测试标准与风格指南 页面。

Vue.js 测试

如果你正在寻找关于 Vue 组件测试的指南,可以直接跳转到此 章节

有关 Vue 3 测试的信息包含在 此页面 中。

Jest

我们使用 Jest 编写前端单元和集成测试。Jest 测试可以在 EE 中的 /spec/frontend/ee/spec/frontend 找到。

jsdom

Jest 使用 jsdom 而非浏览器运行测试。 已知问题包括:

另见支持在浏览器中运行 Jest 测试的问题 issue

调试 Jest 测试

运行 yarn jest-debug 以调试模式运行 Jest,允许你按 Jest 文档 中所述进行调试/检查。

超时错误

Jest 的默认超时时间设置在 /jest.config.base.js 中。

如果你的测试超过该时间,则会失败。

如果你无法提高测试性能,可以使用 jest.setTimeout 为整个套件增加超时时间:

jest.setTimeout(500);

describe('组件', () => {
  it('做一些很棒的事', () => {
    // ...
  });
});

或者通过向 it 提供第三个参数来为特定测试增加超时时间:

describe('组件', () => {
  it('做一些很棒的事', () => {
    // ...
  }, 500)
})

记住,每个测试的性能取决于环境。

测试特定的样式表

为了帮助 RSpec 集成测试,我们有两个测试特定的样式表。它们可用于禁用动画以提高测试速度,或在需要被 Capybara 点击事件定位时使元素可见:

  • app/assets/stylesheets/disable_animations.scss
  • app/assets/stylesheets/test_environment.scss

由于测试环境应尽可能匹配生产环境,因此请尽量少用这些样式表,仅在必要时添加内容。

测试什么以及如何测试

在深入探讨 Jest 特定的工作流程(如 mock 和 spy)之前,我们应该简要介绍一下使用 Jest 测试哪些内容。

不要测试库

库是任何JavaScript开发者生活中不可或缺的一部分。一般建议是不要测试库的内部实现,但要期望该库知道它应该做什么,并且自身具备测试覆盖率。 一个通用的示例如下:

import { convertToFahrenheit } from 'temperatureLibrary'

function getFahrenheit(celsius) {
  return convertToFahrenheit(celsius)
}

测试我们的getFahrenheit函数是没有意义的,因为它下面除了调用库函数外什么也没做,我们可以期望那个函数按预期工作。

让我们快速看看Vue领域的情况。Vue是GitLab JavaScript代码库的关键组成部分。在为Vue组件编写测试时,一个常见的陷阱实际上是最终测试了Vue提供的功能,因为这看起来是最容易测试的东西。以下是我们代码库中的一个示例。

// 组件脚本
{
  computed: {
    hasMetricTypes() {
      return this.metricTypes.length;
    }
  }
}
<!-- 组件模板 -->
<template>
  <gl-dropdown v-if="hasMetricTypes">
    <!-- 下拉菜单内容 -->
  </gl-dropdown>
</template>

在这里测试hasMetricTypes计算属性似乎是理所当然的。但要测试计算属性是否返回metricTypes的长度,实际上是在测试Vue库本身。这没有任何价值,只会增加测试套件的内容。最好以用户与组件交互的方式测试组件:检查渲染后的模板。

// 不好的做法
describe('computed', () => {
  describe('hasMetricTypes', () => {
    it('如果存在metricTypes则返回true', () => {
      factory({ metricTypes });
      expect(wrapper.vm.hasMetricTypes).toBe(2);
    });

    it('如果没有metricTypes则返回0', () => {
      factory();
      expect(wrapper.vm.hasMetricTypes).toBe(0);
    });
  });
});

// 好的做法
it('如果存在metricTypes则显示下拉菜单', () => {
  factory({ metricTypes });
  expect(wrapper.findComponent(GlDropdown).exists()).toBe(true);
});

it('如果没有metricTypes则不显示下拉菜单', () => {
  factory();
  expect(wrapper.findComponent(GlDropdown).exists()).toBe(false);
});

要注意这类测试,因为它们会让更新逻辑变得比必要的更脆弱和繁琐。对于其他库也是如此。这里有个建议:如果你正在检查wrapper.vm属性,你应该停下来重新思考测试,改为检查渲染后的模板。

更多示例可以在前端单元测试部分找到。

不要测试你的Mock

另一个常见陷阱是规范最终验证了Mock是否在工作。如果你使用Mock,Mock应该支持测试,但不能成为测试的目标。

const spy = jest.spyOn(idGenerator, 'create')
spy.mockImplementation(() => '1234')

// 不好的做法
expect(idGenerator.create()).toBe('1234')

// 好的做法:真正专注于组件的逻辑,并利用可控制的Mock输出
expect(wrapper.find('div').html()).toBe('<div id="1234">...</div>')

不要在断言中使用导入的值

在断言中优先使用字面值而非导入常量。这让测试更容易阅读,且对变更更具韧性。这在i18n建议中有进一步讨论。

// 不好的做法:MY_CONSTANT 可能意外被设置为undefined、有拼写错误等,而测试仍会通过
import { MY_CONSTANT } from '../constants';

it('返回正确的值', () => {
  expect(ding()).toBe(MY_CONSTANT);
});

// 好的做法:明确断言字面值
it('返回正确的值', () => {
  expect(ding()).toBe('expected literal value');
});

跟随用户流程

在组件密集的世界里,单元测试和集成测试之间的界限可能会相当模糊。最重要的指导原则如下:

  • 如果测试复杂逻辑的隔离能防止其未来出错有价值,就编写干净的单元测试
  • 否则,尽量让规范贴近用户的流程来编写

例如,最好使用生成的标记来触发按钮点击并验证标记相应变化,而不是手动调用方法并验证数据结构或计算属性。总是有可能意外破坏用户流程,而测试却通过了,给人虚假的安全感。

通用实践

这些是一些包含在我们测试套件中的通用实践。如果你遇到不符合本指南的内容,理想情况下应立即修复。🎉

如何查询DOM元素

在测试中查询DOM元素时,最好唯一且语义化地定位元素。

首选方法是针对用户实际看到的内容使用DOM Testing Library。 选择文本时,最好使用byRole查询,因为它有助于执行最佳无障碍实践。使用shallowMountExtendedmountExtended时,findByRole和其他DOM Testing Library查询可用。

编写Vue组件单元测试时,按组件查询子组件可能更明智,这样单元测试可以专注于全面的价值覆盖,而不是处理子组件行为的复杂性。

有时上述方法都不可行。在这种情况下,添加测试属性以简化选择器可能是最佳选择。可能的选择器包括:

import { shallowMountExtended } from 'helpers/vue_test_utils_helper'

const wrapper = shallowMountExtended(ExampleComponent);

it('exists', () => {
  // 最佳(尤其适用于集成测试)
  wrapper.findByRole('link', { name: /Click Me/i })
  wrapper.findByRole('link', { name: 'Click Me' })
  wrapper.findByText('Click Me')
  wrapper.findByText(/Click Me/i)

  // 良好(尤其适用于单元测试)
  wrapper.findComponent(FooComponent);
  wrapper.find('input[name=foo]');
  wrapper.find('[data-testid="my-foo-id"]');
  wrapper.findByTestId('my-foo-id'); // 使用shallowMountExtended或mountExtended时,见下方

  // 不良
  wrapper.find({ ref: 'foo'});
  wrapper.find('.js-foo');
  wrapper.find('.gl-button');
});

你应该对data-testid属性使用kebab-case

不建议仅为测试目的添加.js-*类。只有在没有其他可行选项的情况下才这样做。 避免在测试中使用Vue模板ref来查询DOM元素,因为它们是组件的实现细节,不是公共API。

查询子组件

使用@vue/test-utils测试Vue组件时,另一种可能的方案是查询子组件而不是查询DOM节点。这假设被测行为的具体实现应由该组件的单独单元测试覆盖。编写DOM或组件查询没有强烈偏好,只要你的测试可靠地覆盖了被测组件的预期行为即可。

示例:

it('exists', () => {
  wrapper.findComponent(FooComponent);
});

命名单元/组件测试

单元/组件测试应命名为${componentName}_spec.js

如果测试名称不够具体,考虑重命名组件。

例如: diff_stats_dropdown.vue应有一个名为diff_stats_dropdown_spec.js的单元/组件测试

Describe块命名

编写用于测试特定函数/方法的describe测试块时,请使用方法名作为describe块的名称。

不良

describe('#methodName', () => {
  it('passes', () => {
    expect(true).toEqual(true);
  });
});

describe('.methodName', () => {
  it('passes', () => {
    expect(true).toEqual(true);
  });
});

良好

describe('methodName', () => {
  it('passes', () => {
    expect(true).toEqual(true);
  });
});

测试 Promise

当测试 Promise 时,你应该始终确保测试是异步的并且处理了拒绝(rejections)。现在可以在测试套件中使用 async/await 语法:

it('测试一个 Promise', async () => {
  const users = await fetchUsers()
  expect(users.length).toBe(42)
});

it('测试一个 Promise 拒绝', async () => {
  await expect(user.getUserName(1)).rejects.toThrow('未找到 ID 为 1 的用户。');
});

你也可以从测试函数中返回一个 Promise。

在使用 Promise 时,不建议使用 donedone.fail 回调。它们不应被使用。

错误示例

// 缺少 return
it('测试一个 Promise', () => {
  promise.then(data => {
    expect(data).toBe(asExpected);
  });
});

// 使用了 done/done.fail
it('测试一个 Promise', done => {
  promise
    .then(data => {
      expect(data).toBe(asExpected);
    })
    .then(done)
    .catch(done.fail);
});

正确示例

// 验证已解决的 Promise
it('测试一个 Promise', () => {
  return promise
    .then(data => {
      expect(data).toBe(asExpected);
    });
});

// 使用 Jest 的 `resolves` 匹配器验证已解决的 Promise
it('测试一个 Promise', () => {
  return expect(promise).resolves.toBe(asExpected);
});

// 使用 Jest 的 `rejects` 匹配器验证被拒绝的 Promise
it('测试一个 Promise 拒绝', () => {
  return expect(promise).rejects.toThrow(expectedError);
});

操作时间

有时我们需要测试与时间相关的代码。例如,每 X 秒运行的重复事件或类似情况。以下是应对这种情况的一些策略:

应用中的 setTimeout() / setInterval()

如果应用程序本身正在等待一段时间,请模拟等待过程。在 Jest 中这已经是默认完成的(另见Jest 计时器模拟)。

const doSomethingLater = () => {
  setTimeout(() => {
    // 执行某些操作
  }, 4000);
};

在 Jest 中

it('执行某项操作', () => {
  doSomethingLater();
  jest.runAllTimers();

  expect(something).toBe('完成');
});

在 Jest 中模拟当前位置

window.location.href 的值在每个测试前都会重置,以避免之前的测试影响后续测试。

如果你的测试需要 window.location.href 取特定值,请使用 setWindowLocation 辅助函数:

import setWindowLocation from 'helpers/set_window_location_helper';

it('通过', () => {
  setWindowLocation('https://gitlab.test/foo?bar=true');

  expect(window.location).toMatchObject({
    hostname: 'gitlab.test',
    pathname: '/foo',
    search: '?bar=true',
  });
});

若仅需修改哈希(hash),可使用 setWindowLocation 辅助函数,或直接赋值给 window.location.hash,例如:

it('通过', () => {
  window.location.hash = '#foo';

  expect(window.location.href).toBe('http://test.host/#foo');
});

如果你的测试需要断言某些 window.location 方法已被调用,请使用 useMockLocationHelper 辅助函数:

import { useMockLocationHelper } from 'helpers/mock_window_location_helper';

useMockLocationHelper();

it('通过', () => {
  window.location.reload();

  expect(window.location.reload).toHaveBeenCalled();
});

测试事件监听器和超时的清理

在组件中,我们经常会在 beforeDestroy(Vue 3 中为 beforeUnmount)钩子中创建事件监听器或超时。测试组件实例销毁时是否清除了这些监听器和超时非常重要,因为忘记清理这些事件可能导致内存泄漏和事件监听器上的引用断裂等问题。

考虑以下示例:

beforeDestroy() {
  removeEventListener('keydown', someListener)
  clearTimeout(timeoutPointer)
}

在上面的示例中,组件同时清除了一个 keydown 事件监听器和一个在其他地方创建的超时。

让我们看看相关的测试。

describe('Cleanup before destroy', () => {
  beforeEach(() => {
    createComponent()

    // 立即销毁组件以触发 `beforeDestroy` 钩子
    wrapper.destroy()
  })

  it('移除事件监听器', () => {
    const spy = jest.spyOn(window, 'removeEventListener')
    expect(spy).toHaveBeenCalledTimes(1)
    expect(spy).toHaveBeenCalledWith('keydown', expect.any(Function))
  })

  it('清除待处理的超时', () => {
    const spy = jest.spyOn(window, 'clearTimeout')
    expect(spy).toHaveBeenCalledTimes(1)
  })
})

上面的示例没有显式检查 keydown 监听器调用的函数,因为这通常是实现细节。对 clearTimeout 调用也是如此,因为参数将是组件内部创建的计时器的指针。

因此,通常只需检查间谍函数是否被调用即可,建议额外检查它们被调用的次数。

测试中的等待

有时测试需要在应用程序中发生某些事情后才能继续。

你应该避免:

  • setTimeout,因为它使等待的原因变得不明确。此外,在我们的测试中被模拟,使用起来很棘手。
  • setImmediate,因为在 Jest 27 及更高版本中不再支持。详情请参见 此史诗级任务

Promises 和 Ajax 调用

注册处理函数以等待 Promise 解析。

const askTheServer = () => {
  return axios
    .get('/endpoint')
    .then(response => {
      // 做一些事
    })
    .catch(error => {
      // 做其他事
    });
};

在 Jest 中

it('等待一个 Ajax 调用', async () => {
  await askTheServer()
  expect(something).toBe('done');
});

如果你无法向 Promise 注册处理程序,例如因为它在同步的 Vue 生命周期钩子中执行,可以查看 waitFor 助手或通过以下方式刷新所有待处理的 Promise

在 Jest 中

it('等待一个 Ajax 调用', async () => {
  synchronousFunction();

  await waitForPromises();

  expect(something).toBe('done');
});

Vue 渲染

使用 nextTick() 等待 Vue 组件重新渲染。

在 Jest 中

import { nextTick } from 'vue';

// ...

it('渲染某物', async () => {
  wrapper.setProps({ value: '新值' });

  await nextTick();

  expect(wrapper.text()).toBe('新值');
});

事件

如果应用程序触发了你需要等待的事件,注册包含断言的事件处理程序:

it('等待一个事件', () => {
  eventHub.$once('someEvent', eventHandler);

  someFunction();

  return new Promise((resolve) => {
    function expectEventHandler() {
      expect(something).toBe('done');
      resolve();
    }
  });
});

在 Jest 中你也可以使用 Promise 来实现:

it('等待一个事件', () => {
  const eventTriggered = new Promise(resolve => eventHub.$once('someEvent', resolve));

  someFunction();

  return eventTriggered.then(() => {
    expect(something).toBe('done');
  });
});

操作 gon 对象

gon(或 window.gon)是一个全局对象,用于从后端传递数据。如果你的测试依赖于其值,可以直接修改它:

describe('当已登录时', () => {
  beforeEach(() => {
    gon.current_user_id = 1;
  });

  it('显示消息', () => {
    expect(wrapper.text()).toBe('已登录!');
  })
})

每个测试都会重置 gon 以确保测试隔离。

确保测试隔离

测试通常采用一种架构模式,需要重复设置被测组件。这通常通过使用 beforeEach 钩子来实现。

示例:

javascript\n let wrapper;\n\n beforeEach(() => {\n wrapper = mount(Component);\n });\n\n\n借助 enableAutoDestroy,不再需要手动调用 wrapper.destroy()。\n但是,一些模拟对象、间谍函数和固定装置确实需要被拆除,我们可以利用 afterEach 钩子。\n\n示例:\n\njavascript\n let wrapper;\n\n afterEach(() => {\n fakeApollo = null;\n store = null;\n });\n\n\n### 测试仅限本地的 Apollo 查询和变更\n\n在将新查询或变更添加到后端之前,我们可以使用 @client 指令。例如:\n\ngraphql\nmutation setActiveBoardItemEE($boardItem: LocalBoardItem, $isIssue: Boolean = true) {\n setActiveBoardItem(boardItem: $boardItem) @client {\n ...Issue @include(if: $isIssue)\n ...EpicDetailed @skip(if: $isIssue)\n }\n}\n\n\n在为这类调用编写测试用例时,我们可以使用解析器来确保它们以正确的参数被调用。\n\n例如,在创建包装器时,我们应该确保解析器映射到了查询或变更上。\n我们在这里模拟的变更是 setActiveBoardItem:\n\njavascript\nconst mockSetActiveBoardItemResolver = jest.fn();\nconst mockApollo = createMockApollo([], {\n Mutation: {\n setActiveBoardItem: mockSetActiveBoardItemResolver,\n },\n});\n\n\n在下面的代码中,我们必须传递四个参数。第二个必须是模拟的查询或变更的输入变量集合。\n要测试该变更是否以正确参数被调用:\n\njavascript\nit(\'calls setActiveBoardItemMutation on close\', async () => {\n wrapper.findComponent(GlDrawer).vm.$emit(\'close\');\n\n await waitForPromises();\n\n expect(mockSetActiveBoardItemResolver).toHaveBeenCalledWith(\n {},\n {\n boardItem: null,\n },\n expect.anything(),\n expect.anything(),\n );\n});\n\n\n### Jest 最佳实践\n\n#### 比较原始值时优先使用 toBe 而非 toEqual\n\nJest 有 toBe 和 \ntoEqual 匹配器。\n由于 toBe 使用 \nObject.is\n比较值,因此它比使用 toEqual 更快(默认情况下)。后者最终会回退到利用 \nObject.is,\n但对于原始值,只有当需要比较复杂对象时才应使用它。\n\n示例:\n\njavascript\nconst foo = 1;\n\n// 不佳\nexpect(foo).toEqual(1);\n\n// 良好\nexpect(foo).toBe(1);\n\n\n#### 优先使用更合适的匹配器\n\nJest 提供了诸如 toHaveLengthtoBeUndefined 等有用的匹配器,使您的测试更具可读性并产生更易理解的错误消息。查看其文档中的 完整匹配器列表。\n\n示例:\n\njavascript\nconst arr = [1, 2];\n\n// 输出:\n// Expected length: 1\n// Received length: 2\nexpect(arr).toHaveLength(1);\n\n// 输出:\n// Expected: 1\n// Received: 2\nexpect(arr.length).toBe(1);\n\n// 输出:\n// expect(received).toBe(expected) // Object.is equality\n// Expected: undefined\n// Received: "bar"\nconst foo = \'bar\';\nexpect(foo).toBe(undefined);\n\n// 输出:\n// expect(received).toBeUndefined()\n// Received: "bar"\nconst foo = \'bar\';\nexpect(foo).toBeUndefined();\n\n\n#### 避免使用 toBeTruthytoBeFalsy\n\nJest 还提供了以下匹配器:toBeTruthytoBeFalsy。我们不应使用它们,因为它们会使测试变得脆弱并产生误报结果。\n\n例如,当 someBoolean === nullsomeBoolean === false 时,expect(someBoolean).toBeFalsy() 都会通过。\n\n#### 易混淆的 toBeDefined 匹配器\n\nJest 的棘手 toBeDefined 匹配器可能会产生误报测试。因为它只 验证 给定值是否为 undefined。\n\njavascript\n// 不佳:如果查找器返回 null,测试将通过\nexpect(wrapper.find(\'foo\')).toBeDefined();\n\n// 良好\nexpect(wrapper.find(\'foo\').exists()).toBe(true);\n\n\n#### 避免使用 setImmediate\n\n尽量避免使用 setImmediatesetImmediate 是一个临时解决方案,用于在 I/O 完成后运行回调。它不是 Web API 的一部分,因此我们在单元测试中针对 Node.js 环境。\n\n不要使用 setImmediate,而是使用 jest.runAllTimersjest.runOnlyPendingTimers 来运行待处理的定时器。后者在代码中有 setInterval 时很有用。记住:我们的 Jest 配置使用了虚拟定时器。

避免非确定性测试

非确定性是产生易碎测试的温床。这类测试最终会破坏CI流水线,中断其他贡献者的工作流程。

  1. 确保你的测试对象的合作者(例如Axios、Apollo、Lodash辅助函数)和测试环境(例如Date)在系统和时间上行为一致。
  2. 确保测试聚焦且不做“额外工作”(例如,在一个单独测试中不必要的多次创建测试对象)。

为确定性模拟Date

在我们的Jest环境中,Date默认被模拟。这意味着每次调用Date()Date.now()都会返回一个固定的确定性值。

如果你确实需要更改默认的模拟日期,可以在任何describe块内调用useFakeDate,并且该describe上下文内的测试将仅替换其日期:

import { useFakeDate } from 'helpers/fake_date';

describe('cool/component', () => {
  // 默认模拟的Date
  const TODAY = new Date();

  // 注意:`useFakeDate`不能在测试执行期间调用(即inside `it`、`beforeEach`、`beforeAll`等)。
  describe("在Ada Lovelace的生日时", () => {
    useFakeDate(1815, 11, 10)

    it('Date不再是默认值', () => {
      expect(new Date()).not.toEqual(TODAY);
    });
  });

  it('在此作用域内Date仍为默认值', () => {
    expect(new Date()).toEqual(TODAY)
  });
})

类似地,如果你确实需要使用真实的Date类,则可以在任何describe块内导入并调用useRealDate

import { useRealDate } from 'helpers/fake_date';

// 注意:`useRealDate`不能在测试执行期间调用(即inside `it`、`beforeEach`、`beforeAll`等)。
describe('使用真实日期', () => {
  useRealDate();
});

为确定性模拟Math.random

当测试对象依赖Math.random时,考虑用模拟替换它。

beforeEach(() => {
  // https://xkcd.com/221/
  jest.spyOn(Math, 'random').mockReturnValue(0.4);
});

测试中的控制台警告和错误

意外的控制台警告和错误表明我们的生产代码存在问题。我们希望测试环境严格,因此当出现意外console.errorconsole.warn调用时,你的测试应失败。

忽略来自观察器的控制台消息

由于有很多不在我们控制范围内的代码,有些控制台消息默认会被忽略,如果使用它们不会导致测试失败。这个忽略列表可以在调用setupConsoleWatcher的地方维护。示例:

setupConsoleWatcher({
  ignores: [
    ...,
    // 任何对`console.error('Foo bar')`或`console.warn('Foo bar')`的调用都将被我们的控制台观察器忽略。
    'Foo bar',
    // 使用正则表达式实现灵活的消息匹配。
    /Lorem ipsum/,
  ]
});

如果一个特定测试需要在某个describe块中忽略特定消息,请在describe顶部附近使用ignoreConsoleMessages助手。这会自动调用beforeAllafterAll来设置/清理此测试上下文的忽略集合。

谨慎使用此功能,仅在绝对必要以保证测试可维护性时使用。示例:

import { ignoreConsoleMessages } from 'helpers/console_watcher';

describe('foos/components/foo.vue', () => {
  describe('当blooped时', () => {
    // 如果调用了`console.warn('Lorem ipsum')`,测试不会失败
    ignoreConsoleMessages([
      /^Lorem ipsum/
    ]);
  });

  describe('默认情况', () => {
    // 如果调用了`console.warn('Lorem ipsum')`,测试会失败
  });
});

Factories

待定(TBU)

Jest的模拟策略

存根与模拟

存根或间谍通常可以互换使用。在Jest中,借助.spyOn方法很容易实现。官方文档 更具挑战性的是模拟,可用于函数甚至依赖项。

手动模块模拟

手动模拟用于在整个Jest环境中模拟模块。这是一种非常强大的测试工具,通过模拟那些在我们测试环境中难以使用的模块,简化了单元测试。

如果模拟不应在所有测试中一致应用(即仅少数测试需要),请不要使用手动模拟。 相反,考虑在相关规范文件中使用jest.mock(..) (或类似的模拟函数)。

你应该在哪里放置手动模拟?

Jest 通过在源模块旁边的 __mocks__/ 目录中放置模拟文件来支持手动模块模拟(例如 app/assets/javascripts/ide/__mocks__)。不要这样做。 我们希望将所有测试相关代码集中在一个地方(spec/ 文件夹)。

如果需要对 node_modules 包进行手动模拟,请使用 spec/frontend/__mocks__ 文件夹。以下是一个Jest 模拟 monaco-editor的示例。

如果需要对 CE 模块进行手动模拟,将实现放在 spec/frontend/__helpers__/mocks 中,并在 frontend/test_setup(或 frontend/shared_test_setup)中添加类似这样的行:

// '~/lib/utils/axios_utils' 是真实模块的路径
// 'helpers/mocks/axios_utils' 是模拟实现的路径
jest.mock('~/lib/utils/axios_utils', () => jest.requireActual('helpers/mocks/axios_utils'));

手动模拟示例

  • __helpers__/mocks/axios_utils - 此模拟很有帮助,因为我们不希望任何未模拟的请求通过测试。此外,我们能够注入一些测试助手,例如 axios.waitForAll
  • __mocks__/mousetrap/index.js - 此模拟很有帮助,因为该模块本身使用 Webpack 能理解的 AMD 格式,但在 Jest 环境中不兼容。此模拟不会移除任何行为,仅提供一个友好的 ES6 兼容包装器。
  • __mocks__/monaco-editor/index.js - 此模拟很有帮助,因为 Monaco 包在 Jest 环境中完全不兼容。事实上,Webpack 需要一个特殊加载器才能使其工作。此模拟使该包可在 Jest 中使用。

保持模拟简洁

全局模拟会引入“魔法”,技术上可能会降低测试覆盖率。当模拟被认为有价值时:

  • 保持模拟简短且聚焦。
  • 在模拟中留下顶级注释,说明其必要性。

其他模拟技巧

有关可用模拟功能的完整概述,请参阅官方 Jest 文档

运行前端测试

在生成 fixures 前,请确保有一个正在运行的 GDK 实例。

要运行前端测试,您需要以下命令:

  • rake frontend:fixtures(重新)生成fixures。在运行需要它们的测试之前,请确保 fixures 是最新的。
  • yarn jest 运行 Jest 测试。

运行 CE 和 EE 测试

每当您为 CE 和 EE 环境创建测试时(因为您的更改包含 EE 功能),您需要采取一些步骤以确保它们在本地和管道上都能通过。

查看本节了解如何测试两种环境。

实时测试和专注测试 —— Jest

当您编写测试套件时,您可能希望在监视模式下运行这些规范,以便每次保存时自动重新运行。

# 监视并重新运行匹配名称为 icon 的所有规范
yarn jest --watch icon

# 监视并重新运行一个特定文件
yarn jest --watch path/to/spec/file.spec.js

您也可以在不使用 --watch 标志的情况下运行一些专注测试:

# 运行特定的 Jest 文件
yarn jest ./path/to/local_spec.js

# 运行特定的 Jest 文件夹
yarn jest ./path/to/folder/

# 运行路径包含指定词的所有 Jest 文件
yarn jest term

前端测试 Fixures

前端 Fixures 是包含后端控制器响应的文件。这些响应可以是来自 HAML 模板的 HTML 或 JSON 负载。依赖这些响应的前端测试通常使用 Fixures 来验证与后端代码的正确集成。

使用 Fixures

要导入 JSON 或 HTML Fixure,请使用 test_fixtures 别名 import 它。

import responseBody from 'test_fixtures/some/fixture.json' // 加载 tmp/tests/frontend/fixtures-ee/some/fixture.json

it('发起请求', () => {
  axiosMock.onGet(endpoint).reply(200, responseBody);

  myButton.click();

  // ...
});

生成测试数据

你可以在以下位置找到生成测试数据的代码:

  • spec/frontend/fixtures/,用于运行 CE 环境下的测试。
  • ee/spec/frontend/fixtures/,用于运行 EE 环境下的测试。

你可以通过运行以下命令生成测试数据:

  • bin/rake frontend:fixtures 生成所有测试数据
  • bin/rspec spec/frontend/fixtures/merge_requests.rb 生成特定测试数据(此例中为 merge_request.rb

生成的测试数据位于 tmp/tests/frontend/fixtures-ee

若要为 _spec.js 文件生成单个测试数据,需从 test_fixtures/ 目录识别导入内容:

// spec/frontend/authentication/webauthn/authenticate_spec.js

import htmlWebauthnAuthenticate from 'test_fixtures/webauthn/authenticate.html';

对应的测试数据文件是 spec/frontend/fixtures/webauthn.rb

要从命令行生成该单个测试数据,运行 bin/rspec spec/frontend/fixtures/webauthn.rb

下载测试数据

我们在 GitLab CI 中生成测试数据,并将其存储在 package registry 中。

scripts/frontend/download_fixtures.sh 脚本用于下载并解压这些测试数据以供本地使用:

# 检查 gitlab-org/gitlab 的 package registry 中是否存在前端测试数据包,
# 方法是通过查看本地分支上的提交记录。

# 若存在则下载并解压
$ scripts/frontend/download_fixtures.sh

# 与上述相同,但仅检查当前检出分支的最后 10 条提交记录
$ scripts/frontend/download_fixtures.sh --max-commits=10

# 检查本地 master 分支的提交记录,而非当前检出分支
$ scripts/frontend/download_fixtures.sh --branch master

创建新的测试数据

对于每个测试数据,你可在输出文件中找到 response 变量的内容。例如,spec/frontend/fixtures/merge_requests.rb 中名为 "merge_requests/diff_discussion.json" 的测试会生成输出文件 tmp/tests/frontend/fixtures-ee/merge_requests/diff_discussion.json。若测试标记为 type: :requesttype: :controllerresponse 变量会自动设置。

创建新测试数据时,通常建议查看对应端点在 (ee/)spec/controllers/(ee/)spec/requests/ 中的测试。

GraphQL 查询测试数据

你可使用 get_graphql_query_as_string 辅助方法创建代表 GraphQL 查询结果的测试数据。例如:

# spec/frontend/fixtures/releases.rb

describe GraphQL::Query, type: :request do
  include GraphqlHelpers

  all_releases_query_path = 'releases/graphql/queries/all_releases.query.graphql'

  it "graphql/#{all_releases_query_path}.json" do
    query = get_graphql_query_as_string(all_releases_query_path)

    post_graphql(query, current_user: admin, variables: { fullPath: project.full_path })

    expect_graphql_errors_to_be_empty
  end
end

这将创建一个位于 tmp/tests/frontend/fixtures-ee/graphql/releases/graphql/queries/all_releases.query.graphql.json 的新测试数据。

你可通过 test_fixtures 别名在 Jest 测试中导入 JSON 测试数据,具体方式如 前文所述

数据驱动测试

类似于 RSpec的参数化测试,Jest 支持数据驱动的测试用于:

这些功能有助于减少测试中的重复。每个选项都可以接受一个数据值数组或标记模板字面量。

例如:

// 待测试函数
const icon = status => status ? 'pipeline-passed' : 'pipeline-failed'
const message = status => status ? 'pipeline-passed' : 'pipeline-failed'

// 数组块测试
it.each([
    [false, 'pipeline-failed'],
    [true, 'pipeline-passed']
])('状态为 %s 时返回 %s',
 (status, icon) => {
    expect(renderPipeline(status)).toEqual(icon)
 }
);

仅当规范输出不需要美观打印时才使用模板字面量块。例如,空字符串、嵌套对象等。

例如,当测试空搜索字符串和非空搜索字符串之间的差异时,应优先使用带有美观打印选项的数组块语法。这样,空字符串('')和非空字符串('search string')的差异将在规范输出中可见。而使用模板字面量块时,空字符串会被显示为一个空格,这可能会导致开发人员体验混乱。

// 不良实践
it.each`
    searchTerm | expected
    ${''} | ${{ issue: { users: { nodes: [] } } }}
    ${'search term'} | ${{ issue: { other: { nested: [] } } }}
`('当搜索词为 $searchTerm 时,返回 $expected', ({ searchTerm, expected }) => {
  expect(search(searchTerm)).toEqual(expected)
});

// 良好实践
it.each([
    ['', { issue: { users: { nodes: [] } } }],
    ['search term', { issue: { other: { nested: [] } } }],
])('当搜索词为 %p 时,预期返回 %p',
 (searchTerm, expected) => {
    expect(search(searchTerm)).toEqual(expected)
 }
);
// 带标记模板字面量块的测试套件
describe.each`
    status   | icon                 | message
    ${false} | ${'pipeline-failed'} | ${'Pipeline failed - boo-urns'}
    ${true}  | ${'pipeline-passed'} | ${'Pipeline succeeded - win!'}
`('管道组件', ({ status, icon, message }) => {
    it(`状态为 ${status} 时返回图标 ${icon}`, () => {
        expect(icon(status)).toEqual(message)
    })

    it(`状态为 ${status} 时返回消息 ${message}`, () => {
        expect(message(status)).toEqual(message)
    })
});

注意事项

由于JavaScript导致的RSpec错误

默认情况下,RSpec 单元测试不会在无头浏览器中运行 JavaScript,而是依赖于检查 Rails 生成的 HTML。

如果一个集成测试依赖 JavaScript 才能正确运行,你需要确保该测试在运行时启用了 JavaScript。如果不这样做,测试运行器会显示模糊的错误信息。

要在 RSpec 测试中启用 JavaScript 驱动程序,向单个测试或包含多个需要启用 JavaScript 的测试的上下文块添加 :js

# 对于单个测试
it '展示有关滥用报告的信息', :js do
  # 断言...
end

describe "管理员::滥用报告", :js do
  it '展示有关滥用报告的信息' do
    # 断言...
  end
  it '显示添加到滥用报告的按钮' do
    # 断言...
  end
end

由于异步导入导致的Jest测试超时

如果一个模块在运行时异步导入了其他模块,这些模块必须在运行时由 Jest 加载器进行转译。这可能导致 Jest 超时

如果你遇到此问题,请考虑急切导入该模块,以便 Jest 在编译时编译并缓存它,从而修复运行时超时问题。

考虑以下示例:

// the_subject.js

export default {
  components: {
    // 因为较大且并非总是需要,所以异步导入 Thing。
    Thing: () => import(/* webpackChunkName: 'thing' */ './path/to/thing.vue'),
  }
};

Jest 不会自动转译 thing.vue 模块,根据其大小,可能导致 Jest 超时。我们可以通过如下方式强制 Jest 转译并缓存该模块:

// the_subject_spec.js

import Subject from '~/feature/the_subject.vue';

// 强制 Jest 转译并缓存
// eslint-disable-next-line no-unused-vars
import _Thing from '~/feature/path/to/thing.vue';

不要忽视测试超时。这可能表明存在实际的生产问题。利用这个机会分析生产环境的 webpack 包和 chunk,并确认异步导入是否存在生产问题。

前端测试级别概述

有关前端测试级别的主要信息可在测试级别页面中找到。

与前端开发相关的测试位于以下位置:

  • spec/frontend/,用于Jest测试
  • spec/features/,用于RSpec测试

RSpec会运行完整的特性测试,而Jest目录包含前端单元测试前端组件测试前端集成测试

2018年5月之前,features/还包含由Spinach运行的特性测试。这些测试已在2018年5月从代码库中移除(#23036)。

另请参阅关于测试Vue组件的说明

测试助手

测试助手可在spec/frontend/__helpers__中找到。
若需引入新助手,请将其置于该目录下。

Vuex助手:testAction

我们提供了一款辅助工具以简化操作测试,详情见官方文档

// 建议按此方式使用(传入单一对象参数),便于从测试中直观识别参数  
await testAction({  
  action: actions.actionName,  
  payload: { deleteListId: 1 },  
  state: { lists: [1, 2, 3] },  
  expectedMutations: [{ type: types.MUTATION }],  
  expectedActions: [],  
});  

// 旧用法(新测试勿用)  
testAction(  
  actions.actionName, // 操作  
  {}, // 传递给操作的参数  
  state, // 状态  
  [  
    { type: types.MUTATION },  
    { type: types.MUTATION_1, payload: {} },  
  ], // 已提交的mutation  
  [  
    { type: 'actionName', payload: {} },  
    { type: 'actionName1', payload: {} },  
  ], // 分发的操作  
  done,  
);  

等待Axios请求完成

位于spec/frontend/__helpers__/mocks/axios_utils.js的Axios Utils模拟模块提供了两个适用于Jest测试的帮助方法,可用于触发HTTP请求。
若你无法获取请求的Promise(例如Vue组件在其生命周期内发起请求时),这些方法尤为实用。

  • waitFor(url, callback):在向url发起的请求完成后(无论成败)执行callback
  • waitForAll(callback):在所有待处理请求完成后执行callback;若无待处理请求,则在下一次事件循环中执行callback

两者均会在请求完成后(通过setImmediate())的下一个事件循环中执行callback,以确保.then().catch()处理器能正常执行。

shallowMountExtendedmountExtended

shallowMountExtendedmountExtended工具支持你执行任意可用的DOM Testing Library 查询,只需在查询前添加findfindAll前缀即可。

import { shallowMountExtended } from 'helpers/vue_test_utils_helper';  

describe('FooComponent', () => {  
  const wrapper = shallowMountExtended({  
    template: `  
      <div data-testid="gitlab-frontend-stack">  
        <p>GitLab frontend stack</p>  
        <div role="tablist">  
          <button role="tab" aria-selected="true">Vue.js</button>  
          <button role="tab" aria-selected="false">GraphQL</button>  
          <button role="tab" aria-selected="false">SCSS</button>  
        </div>  
      </div>  
    `,  
  });  

  it('通过`findByTestId`查找元素', () => {  
    expect(wrapper.findByTestId('gitlab-frontend-stack').exists()).toBe(true);  
  });  

  it('通过`findByText`查找元素', () => {  
    expect(wrapper.findByText('GitLab frontend stack').exists()).toBe(true);  
    expect(wrapper.findByText('TypeScript').exists()).toBe(false);  
  });  

  it('通过`findAllByRole`查找元素', () => {  
    expect(wrapper.findAllByRole('tab').length).toBe(3);  
  });  
});  

查看示例:spec/frontend/alert_management/components/alert_details_spec.js

在旧版浏览器中测试

部分回归问题仅影响特定浏览器版本。可通过以下步骤使用Firefox或BrowserStack安装并测试指定浏览器:

BrowserStack

BrowserStack 允许你测试超过1200种移动设备和浏览器。 你可以直接通过 live app 使用它,或者安装 chrome extension 以便快速访问。 使用保存在 GitLab 共享1Password账户Engineering 账户中的凭据登录 BrowserStack。

Firefox

macOS

你可以从发布FTP服务器下载任何旧版本的Firefox:https://ftp.mozilla.org/pub/firefox/releases/

  1. 从网站中选择一个版本,这里以 50.0.1 为例。
  2. 进入mac文件夹。
  3. 选择你喜欢的语言。DMG包在其中。下载它。
  4. 将应用程序拖放到除 Applications 文件夹外的其他文件夹中。
  5. 将应用程序重命名为类似 Firefox_Old 的名称。
  6. 将应用程序移动到 Applications 文件夹。
  7. 打开终端并运行 /Applications/Firefox_Old.app/Contents/MacOS/firefox-bin -profilemanager 来为该Firefox版本创建新的特定配置文件。
  8. 配置文件创建完成后,退出应用,像往常一样再次运行它。你现在拥有了一个可用的旧版Firefox。

Snapshots

Jest快照测试 是防止给定组件的HTML输出出现意外变化的有用方法。它们应在其他测试方法(例如使用 vue-tests-utils 断言元素)无法覆盖所需用例时使用。要在GitLab中使用它们,有几个需要强调的准则:

  • 将快照视为代码
  • 不要把快照文件当作黑盒
  • 关心快照的输出,否则它不会提供任何实际价值。这通常涉及像阅读其他代码一样阅读生成的快照文件

可以将快照测试看作是一种简单的方式来存储被测项内容的原始 String 表示形式。这可用于评估组件、存储、复杂生成输出的变化等。在下面的列表中可以看到更多推荐的 Do's and Don'ts

虽然快照测试是非常强大的工具,但它们旨在补充而非替代单元测试。

Jest提供了关于最佳实践的优秀文档集,我们在创建快照时应牢记这些内容。

快照是如何工作的?

快照是被测项在函数调用左侧的字符串化版本。这意味着你对字符串格式的任何更改都会影响结果。这个过程通过利用序列化器进行自动转换步骤来完成。对于Vue来说,这已经由 vue-jest 包处理,该包提供了适当的序列化器。

如果你的测试规范的结果与生成的快照文件中的不同,你的测试套件会通知你测试失败。

所有详细信息请参见Jest官方文档 https://jestjs.io/docs/snapshot-testing

优缺点

优点

  • 对重要HTML结构的意外变更提供良好警告
  • 设置简便

缺点

  • 缺乏 vue-tests-utils 通过查找元素并直接断言其存在的清晰度或防护栏
  • 在有意更新组件时产生不必要的噪音
  • 捕获错误快照的高风险,这会使测试适得其反,因为修复问题时测试会失败
  • 快照中没有有意义的断言或期望,使得它们更难推理或替换
  • 与依赖项(如 GitLab UI)一起使用时,当下层库更改我们正在测试的组件的HTML时,会导致测试脆弱性

何时使用

使用快照的情况

  • 保护关键HTML结构,避免意外变更
  • 断言复杂实用程序函数的JS对象或JSON输出

何时不应使用

不应使用快照的情况

  • 测试可以用 vue-tests-utils 编写时
  • 断言组件的逻辑
  • 预测数据结构输出
  • 存储库外有UI元素(想想GitLab UI版本更新)

示例

正如你所见,快照测试的缺点通常远大于优点。为了更好地说明这一点,本节将展示一些你可能想使用快照测试的示例以及为何它们不是好的模式。

示例 #1 - 元素可见性

当测试元素可见性时,建议使用 vue-test-utils (VTU) 查找指定组件,再对 VTU 包装器调用基础的 .exists() 方法。这能提升可读性与测试健壮性。若观察下方示例,你会发现快照上的断言并未说明你期望看到的内容。我们完全依赖 it 描述提供上下文,并假定快照已捕获到预期行为。

<template>
  <my-component v-if="isVisible" />
</template>

坏的做法:

it('隐藏组件', () => {
  createComponent({ props: { isVisible: false }})

  expect(wrapper.element).toMatchSnapshot()
})

it('显示组件', () => {
  createComponent({ props: { isVisible: true }})

  expect(wrapper.element).toMatchSnapshot()
})

好的做法:

it('隐藏组件', () => {
  createComponent({ props: { isVisible: false }})

  expect(findMyComponent().exists()).toBe(false)
})

it('显示组件', () => {
  createComponent({ props: { isVisible: true }})

  expect(findMyComponent().exists()).toBe(true)
})

不仅如此,想象你给组件传错属性且可见性异常:快照测试仍会通过——因为你捕获到了包含问题的 HTML。因此,除非你仔细检查快照输出,否则永远无法发现测试已失效。

示例 #2 - 文本存在性

通过 vue-test-utilswrapper.text() 方法,在组件内查找文本很简单。但有时因格式化或 HTML 嵌套导致返回值含大量不一致空格,你可能想用快照。这种场景下,单独断言每个字符串并做多次断言,比用快照忽略空格更好。因为哪怕 DOM 布局变化(只要文本格式仍完美),快照测试也会失败。

<template>
  <gl-sprintf :message="my-message">
    <template #code="{ content }">
      <code>{{ content }}</code>
    </template>
  </gl-sprintf>
  <p> 我的第二条消息 </p>
</template>

坏的做法:

it('按预期渲染文本', () => {
  expect(wrapper.text()).toMatchSnapshot()
})

好的做法:

it('渲染代码片段', () => {
  expect(findCodeTag().text()).toContain("myFunction()")
})

it('渲染段落文本', () => {
  expect(findOtherText().text()).toBe("我的第二条消息")
})

示例 #3 - 复杂 HTML

面对极复杂的 HTML 时,应聚焦于断言特定、关键节点,而非整体捕获。快照测试的价值是警告开发者:他们可能意外修改了非预期的 HTML 结构。若变更输出难读(复杂 HTML 输出常如此),那“某处已变”的信号本身是否足够?若足够,能否不用快照实现?

GlTable 是复杂 HTML 输出的典型例子。快照测试看似合适(可捕获行列结构),但我们应尝试断言预期文本,或手动统计行列数量。

<template>
  <gl-table ...all-them-props />
</template>

坏的做法:

it('按预期渲染 GlTable', () => {
  expect(findGlTable().element).toMatchSnapshot()
})

好的做法:

it('渲染正确行数', () => {
  expect(findGlTable().findAllRows()).toHaveLength(expectedLength)
})

it('渲染仅满月时出现的特殊图标', () => {
  expect(findGlTable().findMoonIcon().exists()).toBe(true)
})

it('渲染正确邮箱格式', () => {
  expect(findGlTable().text()).toContain('[email protected]')
})

虽更冗长,但这意味着若 GlTable 改变内部实现,测试不会崩溃;我们也向其他开发者(或半年后的自己)传递了:重构或扩展表格时需保留的关键信息。

如何拍摄快照

it('makes the name look pretty', () => {
  expect(prettifyName('Homer Simpson')).toMatchSnapshot()
})

当这个测试首次运行时,会生成一个新的 .snap 文件。它的内容大致如下:

// Jest 快照 v1, https://goo.gl/fbAQLP

exports[`makes the name look pretty`] = `
Sir Homer Simpson the Third
`

现在,每次运行此测试时,新快照都会与之前生成的版本进行对比评估。这强调了理解快照文件内容并妥善处理的重要性——若快照输出过于庞大或复杂难以阅读,其价值就会丧失。这意味着应将快照限制在人类可读的项目上:这些项目要么能在合并请求评审中被评估,要么保证永不改变。

wrapperselements 同样适用:

it('renders the component correctly', () => {
  expect(wrapper).toMatchSnapshot()
  expect(wrapper.element).toMatchSnapshot();
})

上述测试会生成两个快照。关键是要判断哪个快照更能保障代码库的安全——即,若某个快照发生变化,是否能凸显代码库潜在的断裂风险?这有助于捕捉底层依赖未经察觉就变更时的意外改动。

开始使用功能测试

什么是功能测试

一个功能测试(也称为 白盒测试),是一种会启动浏览器并使用 Capybara 辅助方法的测试。这意味着它能:

  • 在浏览器中定位元素;
  • 点击该元素;
  • 调用 API。

功能测试运行成本较高。在执行这类测试前,请务必确认真的需要它。

我们的所有功能测试都用 Ruby 编写,但因 JavaScript 工程师常负责实现面向用户的特性,最终往往由他们来写。因此下文假定你无 RubyCapybara 基础,并提供清晰指引说明何时及如何使用这些测试。

何时使用功能测试

当测试需满足以下条件时,应选用功能测试:

  • 涉及多个组件;
  • 要求用户跨页面导航;
  • 需提交表单并在其他地方观察结果;
  • 若作为单元测试实现,会导致大量模拟/存根(用假数据/组件)。

功能测试在以下场景尤为实用:

  • 验证多个组件能否协同工作;
  • 测试复杂的 API 交互(功能测试直接与 API 交互,虽慢但不需任何模拟或固定装置)。

何时不应使用功能测试

若通过 jestvue-test-utils 单元测试能达成相同目标,应优先选择它们而非功能测试——后者运行成本更高。

若满足以下条件,应使用单元测试:

  • 实现的行为完全在一个组件内;
  • 可模拟其他组件行为以触发预期效果;
  • 已能在虚拟 DOM 中选中 UI 元素以触发预期效果。

此外,若新代码的行为需多个组件协作,应在组件树更高层级测试该行为。例如,考虑名为 ParentComponent 的组件:

  <script>
  export default{
    name: ParentComponent,
    data(){
      return {
        internalData: 'oldValue'
      }
    },
     methods:{
      changeSomeInternalData(newVal){
        this.internalData = newVal
      }
     }
  }
  </script>
  <template>
   <div>
    <child-component-1 @child-event="changeSomeInternalData" />
    <child-component-2 :parent-data="internalData" />
   </div>
  </template>

此例中:

  • ChildComponent1 触发事件;
  • ParentComponent 更新 internalData 值;
  • ParentComponentChildComponent2 传递 props。

可通过以下方式用单元测试替代:

  • ParentComponent 单元测试文件内,从 childComponent1 发出预期事件;
  • 确保 prop 已传递给 childComponent2

随后各子组件单元测试事件触发及 prop 变更时的行为。

这一逻辑也适用于更大规模的深层组件树:若你能:

  • 自信地挂载子组件;
  • 在虚拟 DOM 中发出事件或选中元素;
  • 获得期望的测试行为;

则完全值得用单元测试规避功能测试的高成本。

在哪里创建你的测试

功能测试位于 spec/features 文件夹中。你应该查找现有的文件,这些文件可以测试你正在添加功能的页面。在该文件夹内,你可以找到对应的章节。例如,如果你想为管道页面添加新的功能测试,你会查看 spec/features/projects/pipelines 并检查你要编写的测试是否已存在于此处。

如何运行功能测试

  1. 确保你有一个可用的GDK环境。

  2. 使用 gdk start 命令启动你的 gdk 环境。

  3. 在终端中运行:

     bundle exec rspec path/to/file:line_of_my_test

你也可以在这个命令前加上 WEBDRIVER_HEADLESS=0,这样测试会通过打开你电脑上实际可见的浏览器来运行,这对调试非常有用。

若要使用Firefox而不是Chrome,可以在命令前加上 WEBDRIVER=firefox

如何编写测试

基本文件结构

  1. 使所有字符串字面量不可变

    在所有功能测试中,第一行应该是:

    # frozen_string_literal: true

    每个 Ruby 文件都有这个,它使所有字符串字面量不可变。还有一些性能优势,但这超出了本节范围。

  2. 导入依赖项。

    你应该导入所需的模块。你最可能总是需要引入 spec_helper

    require 'spec_helper'

    导入任何其他相关模块。

  3. 为RSpec创建全局作用域以定义我们的测试,就像我们在jest中使用初始describe块所做的那样。

然后,你需要创建第一个 RSpec 作用域。

RSpec.describe 'Pipeline', :js do
  ...
end

不过不同的是,就像Ruby中的所有东西一样,这实际上是一个 class。这意味着在顶部,你可以 include 测试所需的模块。例如,你可以包含 RoutesHelpers 以便更容易导航。

RSpec.describe 'Pipeline', :js do
  include RoutesHelpers
  ...
end

完成所有这些实现后,我们得到的文件看起来像这样:


# frozen_string_literal: true

require 'spec_helper'

RSpec.describe 'Pipeline', :js do
  include RoutesHelpers
end

种子数据(Seeding data)

每个测试都在自己的环境中运行,因此你必须使用工厂来生成所需的数据。例如,要创建一个测试,带你到路由 /namespace/project/-/pipelines/:id/ 的主管道页面。

大多数功能测试至少要求你创建一个用户,因为你希望已登录。如果你不必登录,可以跳过这一步,但作为一般规则,你应该始终创建一个用户,除非你特别测试的是匿名用户访问的功能。这样可以确保你明确设置了一个权限级别,你可以在测试中根据需要进行编辑,以更改或测试新的权限级别,随着章节的变化而变化。要创建用户:

  let(:user) { create(:user) }

这创建了一个保存新用户的变量,我们可以使用 create 是因为我们导入了 spec_helper

然而,我们还没有对这个用户做任何事情,因为它只是一个变量。所以,在规范的 before do 块中,我们可以用该用户登录,以便每个规范都以经过身份验证的用户开始。

  let(:user) { create(:user) }

  before do
    sign_in(user)
  end

现在我们有了一个用户,我们应该看看在断言管道页面的任何内容之前还需要什么。如果你查看路由 /namespace/project/-/pipelines/:id/,我们可以确定我们需要一个项目和一条管道。

所以我们创建一个项目和一条管道,并将它们关联起来。通常在工厂中,子元素需要其父元素作为参数。在这种情况下,管道是项目的子元素。因此我们可以先创建项目,然后在创建管道时传递项目作为参数,这将“绑定”管道到项目。管道也由用户拥有,所以我们也需要用户。例如,这创建了项目和管道:

  let(:user) { create(:user) }
  let(:project) { create(:project, :repository) }
  let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master', sha: project.commit.id, user: user) }

同样地,你可以通过使用构建工厂并传递父管道来创建一个作业(构建):

  create(:ci_build, pipeline: pipeline, stage_idx: 10, stage: 'publish', name: 'CentOS')

已经有很多现成的工厂,所以要确保查看其他现有文件,看看你需要的东西是否存在。

导航

你可以使用 visit 方法并传入路径作为参数来访问页面。Rails 会自动生成辅助路径,因此请确保使用这些路径而不是硬编码的字符串。它们是通过路由模型生成的,所以如果我们想进入一个流水线(pipeline),我们会这样用:

  visit project_pipeline_path(project, pipeline)

在执行任何页面交互或通过 UI 进行异步调用之前,请务必先使用 wait_for_requests 再继续后续操作。

元素交互

有很多不同的方式来查找和与元素交互。 有关最佳实践,请参阅 UI 测试 部分。

要点击按钮,请使用带有按钮内文本字符串的 click_button

  click_button '按钮元素内的文本'

如果你想跟随链接,那么有 click_link

  click_link '链接标签内的文本'

你可以使用 fill_in 来填充输入/表单元素。第一个参数是选择器,第二个是 with:,即要传入的值。

  fill_in 'current_password', with: '123devops'

另外,你也可以使用 find 选择器配合 send_keys 在字段中添加键而不删除之前的文本,或者使用 set 完全替换输入元素的值。

你可以在 功能测试操作 文档中找到更全面的操作列表。

断言

要在页面上断言任何内容,你可以始终访问 page 变量,该变量会自动定义,实际上代表页面文档。这意味着你可以期望 page 具有某些组件,如选择器或内容。以下是一些示例:

  # 查找按钮
  expect(page).to have_button('提交审核')
  # 通过文本查找
  expect(page).to have_text('build')
  # 通过 `href` 值查找
  expect(page).to have_link(pipeline.ref)
  # 通过 data-testid 查找
  # 类似 CSS 选择器,当没有特定匹配器可用时,这是可以接受的。
  expect(page).to have_css('[data-testid="pipeline-multi-actions-dropdown"]')
  # 通过 CSS 选择器查找。这是最后的手段。
  # 例如,当你无法在所需元素上添加属性时。
  expect(page).to have_css('.js-icon-retry')
  # 你可以将这些选择器中的任何一个与 `not_to` 结合使用
  expect(page).not_to have_button('提交审核')
  # 当测试用例有连续的预期时,
  # 建议使用 `:aggregate_failures` 将它们分组
  it '显示问题描述和设计引用', :aggregate_failures do
    expect(page).to have_text('我提到的设计')
    expect(page).to have_link(design_tab_ref)
    expect(page).to have_link(design_ref_a)
    expect(page).to have_link(design_ref_b)
  end

你也可以创建一个子块来查看,以:

  • 缩小断言的范围,减少意外找到其他元素的风险。
  • 确保元素在正确的边界内被找到。
  page.within('[data-testid="pipeline-multi-actions-dropdown"]') do
    ...
  end

你可以在 功能测试匹配器 文档中找到更全面的匹配器列表。

功能标志

默认情况下,每个功能标志都是启用的,无论 YAML 定义如何,还是你在 GDK 中手动设置的标志。要测试功能标志禁用时的情况,你必须手动模拟该标志,最好是在 before do 块中。

  stub_feature_flags(my_feature_flag: false)

如果你正在模拟 ee 功能标志,则使用:

  stub_licensed_features(my_feature_flag: false)

断言浏览器控制台错误

默认情况下,功能测试在发现浏览器控制台错误时不会失败。有时我们希望确保没有意外出现的控制台错误,这可能表明存在集成问题。

若要让功能测试在遇到浏览器控制台错误时失败,可使用 BrowserConsoleHelpers 支持模块中的 expect_page_to_have_no_console_errors

RSpec.describe 'Pipeline', :js do
  after do
    expect_page_to_have_no_console_errors
  end

  # ...
end

expect_page_to_have_no_console_errorsWEBDRIVER=firefox 下无效。仅在使用 Chrome 驱动时才会捕获日志。

有时,我们会希望忽略已知的控制台错误。若要忽略一组消息(即当观察到该消息时测试不会失败),可以向 expect_page_to_have_no_console_errors 传入 allow: 参数:

RSpec.describe 'Pipeline', :js do
  after do
    expect_page_to_have_no_console_errors(allow: [
      "Blow up!",
      /Foo.*happens/
    ])
  end

  # ...
end

spec/support/helpers/browser_console_helpers.rb 中更新 BROWSER_CONSOLE_ERROR_FILTER 常量,以修改应全局忽略的控制台错误列表。

调试

你可以通过添加前缀 WEBDRIVER_HEADLESS=0 来运行测试,从而打开实际浏览器。但测试会快速执行命令,留给你查看的时间很少。

为避免此问题,可在希望 Capybara 停止执行的行写入 binding.pry。此时你处于浏览器环境中,可进行标准操作。若想理解为何无法找到某些元素,可以:

  • 选择元素。
  • 使用控制台和网络标签页。
  • 在浏览器控制台中执行选择器。

在运行 Capybara 的终端中,你也可以执行 next 以逐行遍历测试。这样就能逐一检查每个交互,找出可能导致问题的原因。

在GDK上提升执行速度

运行 Jest 测试套件时,工作线程数量设置为使用机器可用核心数的60%;这能加快执行速度,但会增加内存消耗。有关此机制的更多基准测试,请参阅 issue 456885

更新 ChromeDriver

Selenium 4.6 开始,ChromeDriver 可由随 selenium-webdriver gem 提供的 Selenium Manager 自动管理。你不再需要手动同步 chromedriver。


返回测试文档