InfoGrab DocsInfoGrab Docs

테스트 모범 사례

요약

GitLab에서 테스트는 사후에 덧붙이는 것이 아니라 최우선으로 다루는 대상입니다. 기능을 구현할 때는 올바른 역량을 올바른 방식으로 개발하는 데 집중합니다. 테스트 휴리스틱이 이 문제를 해결하는 데 도움이 됩니다. RSpec 테스트를 실행하려면 다음과 같이 합니다.

테스트 설계#

GitLab에서 테스트는 사후에 덧붙이는 것이 아니라 최우선으로 다루는 대상입니다. 기능을 설계할 때처럼 테스트의 설계도 신중하게 고려해야 합니다.

기능을 구현할 때는 올바른 역량을 올바른 방식으로 개발하는 데 집중합니다. 이렇게 하면 범위를 관리 가능한 수준으로 좁힐 수 있습니다. 기능의 테스트를 구현할 때는 올바른 테스트를 개발하는 것은 물론, 테스트가 실패할 수 있는 중요한 경로를 모두 다뤄야 합니다. 그러면 범위가 빠르게 넓어져 관리하기 어려운 수준에 이를 수 있습니다.

테스트 휴리스틱이 이 문제를 해결하는 데 도움이 됩니다. 테스트 휴리스틱은 버그가 코드에 나타나는 여러 일반적인 방식을 간결하게 다룹니다. 테스트를 설계할 때는 알려진 테스트 휴리스틱을 검토하는 시간을 가지고 테스트 설계에 반영합니다. 유용한 휴리스틱은 핸드북의 테스트 가이드에 문서화되어 있습니다.

RSpec#

RSpec 테스트를 실행하려면 다음과 같이 합니다.

# run test for a file
bin/rspec spec/models/project_spec.rb

# run test for the example on line 10 on that file
bin/rspec spec/models/project_spec.rb:10

# run tests matching the example name has that string
bin/rspec spec/models/project_spec.rb -e associations

# run all tests, will take hours for GitLab codebase!
bin/rspec

Guard를 사용하면 변경 사항을 지속적으로 감시하면서 일치하는 테스트만 실행할 수 있습니다.

bundle exec guard

spring과 guard를 함께 사용하는 경우에는 spring을 활용하도록 SPRING=1 bundle exec guard를 대신 사용합니다.

일반 지침#

  • 최상위 RSpec.describe ClassName 블록은 하나만 사용합니다.
  • 클래스 메서드는 .method로, 인스턴스 메서드는 #method로 설명합니다.
  • 분기 로직을 테스트할 때는 context를 사용합니다(RSpec/AvoidConditionalStatements RuboCop Cop - MR).
  • 테스트의 순서를 클래스 안의 순서와 맞추려고 노력합니다.
  • 가능하면 표 기반 테스트를 우선 사용합니다.
  • Four-Phase Test 패턴을 따르되, 줄바꿈으로 단계를 구분합니다.
  • 'localhost'를 하드코딩하지 말고 Gitlab.config.gitlab.host를 사용합니다.
  • 테스트에서 URL을 문자열로 직접 쓸 때는 example.com, gitlab.example.com을 사용합니다. 이렇게 하면 실제 URL을 사용하지 않게 됩니다.
  • 시퀀스로 생성되는 속성의 절대값을 대상으로 단언하지 않습니다 (참고: 주의 사항).
  • expect_any_instance_of 또는 allow_any_instance_of 사용을 피합니다 (참고: 주의 사항).
  • 훅에 :each 인수를 지정하지 않습니다. 기본값이기 때문입니다.
  • before와 after 훅에서는 :all보다 :context 범위를 사용합니다.
  • 특정 요소에 작동하는 evaluate_script("$('.js-foo').testSomething()")(또는 execute_script)를 사용할 때는, 그 전에 find('.js-foo') 같은 Capybara 매처를 사용해 요소가 실제로 존재하는지 확인합니다.
  • 실행하려는 스펙 일부만 분리하려면 focus: true를 사용합니다.
  • 테스트 하나에 기대 조건이 둘 이상 있으면 :aggregate_failures를 사용합니다.
  • 테스트 자체로 설명이 충분하다면 빈 테스트 설명 블록에는 it do 대신 specify를 사용합니다.
  • 존재하지 않는 값이 필요하면 non_existing_record_id, non_existing_record_iid, non_existing_record_access_level을 사용합니다. 어떤 프로젝트도 사용하지 않는 유효한 해시 스토리지 경로가 필요하면 non_existing_project_hashed_path를 사용합니다.
  • 존재하면 안 되는 레코드에 123, 1234, 999 같은 임의의 값을 사용하지 않습니다. 이 값들은 CI 데이터베이스에 존재할 수 있습니다.
  • Model.maximum(:id) + 1 또는 Model.last.id + 1 같은 쿼리로 사용하지 않는 ID를 계산하지 않습니다. 이러한 쿼리는 동시에 일어나는 데이터베이스 쓰기와 경합할 수 있고 불필요한 쿼리를 추가합니다. 대신 존재하지 않는 레코드 헬퍼를 사용합니다.
  • 존재하지 않는 레코드 ID 헬퍼는 32비트 정수의 최댓값인 ACTIVE_MODEL_INTEGER_MAX를 반환하며, CI 실행 중에는 어떤 시퀀스도 이 값에 도달하지 않습니다.
  • 새 테스트를 작성할 때는 통과하는지 단언하기 전에 예상한 방식으로 실패하는지 먼저 확인합니다. 조건을 반전하거나 테스트 대상 동작을 제거한 상태로 스펙을 실행해 실패 메시지가 의미 있는지 확인합니다. 실패할 수 없는 테스트는 커버리지를 제공하지 못합니다.

애플리케이션 코드 즉시 로딩#

테스트 환경에서는 테스트 실행 시간을 단축하기 위해 기본적으로 애플리케이션 코드를 즉시 로딩하지 않습니다. 테스트를 실행할 때 즉시 로딩을 활성화해야 한다면 GITLAB_TEST_EAGER_LOAD 환경 변수를 사용합니다.

GITLAB_TEST_EAGER_LOAD=1 bin/rspec spec/models/project_spec.rb

테스트가 모든 애플리케이션 코드가 로드되어 있어야 동작한다면 :eager_load 태그를 추가합니다. 이렇게 하면 테스트를 실행하기 전에 애플리케이션 코드가 즉시 로딩됩니다.

Ruby 경고#

스펙을 실행할 때는 기본적으로 사용 중단 경고가 활성화되어 있습니다. 이 경고가 개발자에게 더 잘 보이도록 하면 새로운 Ruby 버전으로 업그레이드하는 데 도움이 됩니다.

환경 변수 SILENCE_DEPRECATIONS를 설정하면 사용 중단 경고를 숨길 수 있습니다. 예를 들면 다음과 같습니다.

# silence all deprecation warnings
SILENCE_DEPRECATIONS=1 bin/rspec spec/models/project_spec.rb

테스트 순서#

모든 신규 스펙 파일은 테스트 순서에 의존하는 불안정한(flaky) 테스트를 드러내기 위해 무작위 순서로 실행됩니다.

무작위로 실행되면 다음과 같이 표시됩니다.

  • 예제 그룹 설명 아래에 # order random 문자열이 추가됩니다.
  • 사용된 시드가 스펙 출력의 테스트 스위트 요약 아래에 표시됩니다. 예를 들면 Randomized with seed 27443입니다.

아직 정해진 순서로 실행되는 스펙 파일의 목록은 rspec_order_todo.yml을 참고합니다.

스펙 파일을 무작위 순서로 실행되게 하려면 다음 명령으로 순서 의존성을 확인합니다.

scripts/rspec_check_order_dependence spec/models/project_spec.rb

스펙이 검사를 통과하면 스크립트가 해당 스펙을 rspec_order_todo.yml에서 자동으로 제거합니다.

스펙이 검사를 통과하지 못하면 무작위 순서로 실행하기 전에 먼저 수정해야 합니다.

테스트 불안정성#

불안정한 테스트를 방지하기 위한 프로세스에 관한 자세한 내용은 Unhealthy tests 페이지를 참고합니다.

테스트 속도 저하#

GitLab에는 방대한 테스트 스위트가 있으며, 병렬화 없이는 실행하는 데 몇 시간이 걸릴 수 있습니다. 정확하고 효과적이면서 동시에 빠른 테스트를 작성하려는 노력이 중요합니다.

테스트 성능은 품질과 속도를 유지하는 데 중요하며, CI 빌드 시간과 그에 따른 고정 비용에 직접적인 영향을 줍니다. 우리는 철저하고 정확하며 빠른 테스트를 원합니다. 여기에서는 이를 달성하는 데 사용할 수 있는 도구와 기법에 관한 정보를 확인할 수 있습니다.

느린 테스트를 방지하기 위한 프로세스에 관한 자세한 내용은 Unhealthy tests 페이지를 참고합니다.

필요하지 않은 기능을 요청하지 않기#

예제 또는 상위 컨텍스트에 어노테이션을 붙이면 예제에 기능을 쉽게 추가할 수 있습니다. 예를 들면 다음과 같습니다.

  • 기능 스펙의 :js는 JavaScript를 완전히 지원하는 헤드리스 브라우저를 실행합니다.
  • :clean_gitlab_redis_cache는 예제에 깨끗한 Redis 캐시를 제공합니다.
  • :request_store는 예제에 요청 저장소를 제공합니다.

테스트 의존성을 줄여야 하며, 기능을 사용하지 않으면 필요한 설정의 양도 줄어듭니다.

특히 :js는 피해야 합니다. 기능 테스트에서 브라우저의 JavaScript 반응성이 필요한 경우(예: Vue.js 컴포넌트 클릭)에만 사용해야 합니다. 헤드리스 브라우저를 사용하는 것은 애플리케이션의 HTML 응답을 파싱하는 것보다 훨씬 느립니다.

팩토리 트레이트에도 같은 원칙이 적용됩니다. :repository 같은 트레이트는 하나의 기능입니다. 이 트레이트는 팩토리가 Git 리포지터리를 생성하게 하며, 이는 주변의 데이터베이스 쓰기보다 훨씬 비용이 큽니다. 테스트가 실제로 사용하는 트레이트만 전달합니다. 리포지터리 트레이트에 관해서는 리포지터리를 참고합니다.

프로파일링: 테스트가 시간을 소비하는 위치 확인#

rspec-stackprof를 사용하면 테스트가 시간을 어디에 쓰는지 보여 주는 플레임 그래프를 생성할 수 있습니다.

이 gem은 JSON 보고서를 생성하며, 이를 https://www.speedscope.app에 업로드하면 대화형으로 시각화할 수 있습니다.

설치#

stackprof gem은 GitLab에 이미 설치되어 있으며, JSON 보고서를 생성하는 스크립트(bin/rspec-stackprof)도 있습니다.

# Optional: install the `speedscope` package to easily upload the JSON report to https://www.speedscope.app
npm install -g speedscope
JSON 보고서 생성#
bin/rspec-stackprof --speedscope=true <your_slow_spec>
# There will be the name of the report displayed when the script ends.

# Upload the JSON report to speedscope.app
speedscope tmp/<your-json-report>.json
플레임 그래프 해석 방법#

플레임 그래프를 해석하고 탐색하는 데 유용한 팁은 다음과 같습니다.

  • 플레임 그래프에는 여러 보기가 있습니다. 함수 호출이 많은 경우(예: 기능 스펙)에는 Left Heavy가 특히 유용합니다.
  • 확대와 축소가 가능합니다. 탐색 문서를 참고합니다.
  • 느린 기능 테스트를 작업하는 경우 검색창에 Capybara::DSL#을 검색하면 수행된 Capybara 동작과 각 동작에 걸리는 시간을 확인할 수 있습니다.

분석 예시는 #414929 또는 #375004를 참고합니다.

팩토리 사용 최적화#

테스트가 느려지는 흔한 원인은 객체를 과도하게 생성하여 연산과 데이터베이스 시간이 늘어나는 것입니다. 팩토리는 개발에 필수적이지만 데이터베이스에 데이터를 너무 쉽게 넣을 수 있게 해서 최적화할 여지가 생길 수 있습니다.

염두에 둘 두 가지 기본 기법은 다음과 같습니다.

  • 줄이기: 객체를 생성하지 않고, 영속화하지 않습니다.
  • 재사용: 공유 객체, 특히 직접 검사하지 않는 중첩 객체는 대체로 공유할 수 있습니다.

생성을 피하려면 다음을 염두에 둘 필요가 있습니다.

  • instance_double과 spy는 FactoryBot.build(...)보다 빠릅니다.
  • FactoryBot.build(...)와 .build_stubbed는 .create보다 빠릅니다.
  • build_stubbed는 대개 build보다 빠릅니다. 데이터베이스에 전혀 접근하지 않고, 가짜 id와 타임스탬프를 할당하며, 연관 레코드를 빌드하지 않고 스텁으로 대체하므로 build가 여전히 일으킬 수 있는 연관 관계 연쇄를 피합니다. 테스트 대상 코드가 객체를 영속화하거나 실제 연관 레코드에 의존하는 경우가 아니라면 build_stubbed를 사용합니다.
  • build, build_stubbed, attributes_for, spy, instance_double을 사용할 수 있다면 객체를 create하지 않습니다. 데이터베이스 영속화는 느립니다.

Factory Doctor를 사용하면 주어진 테스트에서 데이터베이스 영속화가 필요하지 않은 경우를 찾을 수 있습니다.

팩토리 최적화 예시: 1, 2.

# run test for path
FDOC=1 bin/rspec spec/[path]/[to]/[spec].rb

흔한 변경은 create 대신 build 또는 build_stubbed를 사용하는 것입니다.

# Old
let(:project) { create(:project) }

# New
let(:project) { build(:project) }

Factory Profiler는 팩토리를 통한 반복적인 데이터베이스 영속화를 식별하는 데 도움이 됩니다.

# run test for path
FPROF=1 bin/rspec spec/[path]/[to]/[spec].rb

# to visualize with a flamegraph
FPROF=flamegraph bin/rspec spec/[path]/[to]/[spec].rb

팩토리가 대량으로 생성되는 흔한 원인은 팩토리가 연관 관계를 생성하고 다시 생성할 때 발생하는 팩토리 연쇄입니다. total time과 top-level time 수치가 눈에 띄게 차이 나는 것으로 식별할 수 있습니다.

   total   top-level     total time      time per call      top-level time               name

     208           0        9.5812s            0.0461s             0.0000s          namespace
     208          76       37.4214s            0.1799s            13.8749s            project

위 표에서 namespace 객체를 명시적으로 생성한 적이 없지만 (top-level == 0) 모두 암묵적으로 생성되었음을 알 수 있습니다. 그런데도 결국 208개가 생성되었고(프로젝트당 하나) 9.5초가 소요됩니다.

암묵적 상위 연관 관계에서 이름이 지정된 팩토리를 호출할 때마다 하나의 객체를 재사용하려면 FactoryDefault를 사용할 수 있습니다.

RSpec.describe API::Search, factory_default: :keep do
  let_it_be(:namespace) { create_default(:namespace) }

그러면 생성하는 모든 프로젝트가 namespace: namespace로 전달하지 않아도 이 namespace를 사용합니다. let_it_be와 함께 동작하게 하려면 factory_default: :keep을 명시적으로 지정해야 합니다. 이렇게 하면 각 예제마다 기본 팩토리를 다시 생성하는 대신, 스위트의 모든 예제에서 기본 팩토리를 유지합니다.

테스트 예제 사이에 의도하지 않은 의존이 생기는 것을 방지하기 위해 create_default로 생성한 객체는 동결됩니다.

프로젝트를 208개나 만들 필요는 없을 수 있습니다. 하나를 만들어 재사용할 수 있습니다. 또한 생성하는 프로젝트 중 우리가 요청한 것은 약 3분의 1 (76/208)에 불과합니다. 프로젝트에도 기본값을 설정하면 이점이 있습니다.

  let_it_be(:project) { create_default(:project) }

이 경우 total time과 top-level time 수치가 더 가깝게 일치합니다.

   total   top-level     total time      time per call      top-level time               name

      31          30        4.6378s            0.1496s             4.5366s            project
       8           8        0.0477s            0.0477s             0.0477s          namespace
let 알아보기#

테스트에서 객체를 생성하고 변수에 저장하는 방법은 여러 가지입니다. 효율이 낮은 것부터 높은 것 순서로 나열하면 다음과 같습니다.

  • let!은 각 예제가 실행되기 전에 객체를 생성합니다. 또한 예제마다 새 객체를 생성합니다. 객체를 명시적으로 참조하지 않으면서 각 예제 전에 깨끗한 객체를 생성해야 할 때만 이 옵션을 사용해야 합니다.
  • let은 객체를 지연 생성합니다. 객체가 호출될 때까지 생성되지 않습니다. let은 예제마다 새 객체를 생성하므로 일반적으로 비효율적입니다. let은 단순한 값에는 괜찮습니다. 그러나 팩토리 같은 데이터베이스 모델을 다룰 때는 더 효율적인 let 변형이 가장 좋습니다.
  • let_it_be_with_refind는 let_it_be_with_reload와 비슷하게 동작하지만, 전자는 ActiveRecord::Base#find를 호출하고 후자는 ActiveRecord::Base#reload를 호출합니다. 일반적으로 reload가 refind보다 빠릅니다.
  • let_it_be_with_reload는 같은 컨텍스트의 모든 예제에 대해 객체를 한 번 생성하지만, 각 예제가 끝나면 데이터베이스 변경 사항이 롤백되고 object.reload가 호출되어 객체가 원래 상태로 복원됩니다. 즉, 예제 실행 전이나 도중에 객체를 변경할 수 있습니다. 그러나 상태가 다른 모델로 누출되는 경우가 발생할 수 있습니다. 이런 경우에는 특히 예제가 몇 개 없다면 let이 더 쉬운 선택일 수 있습니다.
  • let_it_be는 같은 컨텍스트의 모든 예제에 대해 객체를 한 번 생성합니다. 예제마다 바뀔 필요가 없는 객체에는 let과 let!을 대체할 훌륭한 방법입니다. let_it_be를 사용하면 데이터베이스 모델을 생성하는 테스트의 속도를 크게 높일 수 있습니다. 자세한 내용과 예시는 https://github.com/test-prof/test-prof/blob/master/docs/recipes/let_it_be.md#let-it-be를 참고합니다.

let_it_be 안의 객체는 변경할 수 없습니다. 이 프로젝트에서 let_it_be는 기본적으로 freeze: true이며, 이는 spec/support/let_it_be.rb에 설정되어 있습니다. 객체를 변경하면 그 시점에 FrozenError가 발생하며, 이는 let_it_be 선언 안에서 변경된 객체에 적용되는 주의 사항을 드러냅니다(1, 2).

예제가 객체를 수정해야 한다면 let_it_be_with_reload 또는 let_it_be_with_refind를 사용합니다. 둘 다 freeze: false를 설정하고 앞서 설명한 서로 다른 메커니즘으로 예제 사이에 객체를 복원합니다.

# Raises FrozenError when an example modifies the project
let_it_be(:project) { create(:project) }

# The object can be modified, and is restored between examples
let_it_be_with_reload(:project) { create(:project) }

freeze: false만 사용하면 객체의 동결은 해제되지만 예제 사이에 복원되지는 않습니다. 이후 예제가 관찰할 수 있는 방식으로 객체가 변경되지 않는 경우에만 사용합니다. 예를 들어 변경이 before_all 훅 안에서 일어나는 경우입니다. 확실하지 않다면 let_it_be_with_reload를 사용합니다.

let_it_be의 동결에 관한 자세한 내용은 https://github.com/test-prof/test-prof/blob/master/docs/recipes/let_it_be.md#state-leakage-detection를 참고합니다.

let_it_be는 객체를 한 번만 인스턴스화하고 그 인스턴스를 예제 간에 공유하므로 가장 최적화된 옵션입니다. let_it_be 대신 let이 필요하다고 느껴진다면 let_it_be_with_reload를 시도합니다.

# Old
let(:project) { create(:project) }

# New
let_it_be(:project) { create(:project) }

# If you need to expect changes to the object in the test
let_it_be_with_reload(:project) { create(:project) }

다음은 let_it_be를 사용할 수 없지만 let_it_be_with_reload가 let보다 효율적인 경우의 예시입니다.

let_it_be(:user) { create(:user) }
let_it_be_with_reload(:project) { create(:project) } # The test will fail if `let_it_be` is used

context 'with a developer' do
  before_all do
    project.add_developer(user)
  end

  it 'project has an owner and a developer' do
    expect(project.members.map(&:access_level)).to match_array([Gitlab::Access::OWNER, Gitlab::Access::DEVELOPER])
  end
end

context 'with a maintainer' do
  before_all do
    project.add_maintainer(user)
  end

  it 'project has an owner and a maintainer' do
    expect(project.members.map(&:access_level)).to match_array([Gitlab::Access::OWNER, Gitlab::Access::MAINTAINER])
  end
end

각 before_all은 예제마다가 아니라 컨텍스트마다 한 번 실행되며, 멤버십은 컨텍스트가 끝날 때 롤백됩니다. RSpec/BeforeAllRoleAssignment RuboCop 규칙이 이를 강제합니다. let_it_be 객체에 권한을 할당하는 before 훅은 위반입니다.

팩토리를 통한 멤버 할당#

컨텍스트의 모든 예제에 같은 멤버십이 필요하다면 훅 대신 팩토리에 전달합니다. 프로젝트와 그룹 팩토리는 guests, reporters, developers, maintainers, owners처럼 권한마다 하나씩 일시적 속성을 받습니다. 각 속성은 사용자 한 명 또는 배열을 받습니다.

let_it_be(:reviewer) { create(:user) }
let_it_be(:approver) { create(:user) }
let_it_be(:project) { create(:project, developers: reviewer) }
let_it_be(:group) { create(:group, maintainers: [reviewer, approver]) }

사용자 팩토리도 반대 방향에서 같은 관계를 받으며, guest_of, reporter_of, developer_of, maintainer_of, owner_of 같은 속성을 사용합니다.

let_it_be(:project) { create(:project) }
let_it_be(:maintainer) { create(:user, maintainer_of: project) }

이 속성들은 add_developer 및 다른 권한 메서드와 같은 코드 경로로 멤버십을 생성합니다. 사용자가 한 명이면 동등한 before_all과 비교해 쿼리 수가 줄어들지는 않습니다. 위의 maintainers: [reviewer, approver]처럼 사용자 배열인 경우에는 줄어듭니다. 사용자, 이메일, 기존 멤버십 조회가 before_all을 호출할 때마다가 아니라 배치 전체에 대해 한 번만 실행되지만, 각 멤버십은 여전히 개별적으로 인가되고 삽입됩니다. 어느 경우든 팩토리 속성을 사용하는 것이 좋습니다. 설정이 한곳에 모이고, 객체가 동결되기 전에 멤버십이 존재하므로 스펙에 별도의 훅이나 let_it_be_with_reload가 필요하지 않기 때문입니다.

let을 let_it_be로 변환하지 않아야 하는 경우#

Danger는 프로젝트 팩토리를 발견할 때마다 let(:project) { create(:project) }를 let_it_be로 변환하라고 제안합니다. 이 제안은 휴리스틱이며, 다음과 같은 경우에는 거절해도 됩니다.

  • 예제가 테스트 대상 객체를 수정합니다. let_it_be_with_reload를 사용하거나, 예제가 몇 개 없다면 let을 유지합니다.
  • 팩토리가 allow로 메서드를 스텁합니다. 팩토리 안에서 메서드 스텁하기를 참고합니다.
  • 스펙이 마이그레이션 스펙 또는 Rake 태스크 스펙이거나 :delete 태그가 붙어 있습니다. 공통 테스트 설정을 참고합니다.

예제가 하나뿐인 컨텍스트라는 이유만으로는 근거가 약합니다. 예제가 객체를 사용한다면 let과 let_it_be 모두 객체를 한 번 생성하므로 어느 쪽도 더 빠르지 않습니다. 예외는 일부 예제가 전혀 참조하지 않는 객체입니다. let은 지연 생성이라 이를 건너뛰지만, let_it_be는 즉시 생성이라 항상 생성합니다. 이 이유로 거절하기 전에 객체가 실제로 사용되는지 확인합니다.

제안을 거절할 때는 이 중 어느 경우에 해당하는지 밝혀 두면, 다음에 읽는 사람이 같은 내용을 다시 파악할 필요가 없습니다.

팩토리 안에서 메서드 스텁하기#

팩토리에서는 allow(object).to receive(:method) 사용을 피해야 합니다. 공통 테스트 설정에서 설명한 대로 팩토리를 let_it_be와 함께 사용할 수 없게 되기 때문입니다.

대신 stub_method를 사용해 메서드를 스텁할 수 있습니다.

  before(:create) do |user, evaluator|
    # Stub a method.
    stub_method(user, :some_method) { 'stubbed!' }
    # Or with arguments, including named ones
    stub_method(user, :some_method) { |var1| "Returning #{var1}!" }
    stub_method(user, :some_method) { |var1: 'default'| "Returning #{var1}!" }
  end

  # Un-stub the method.
  # This may be useful where the stubbed object is created with `let_it_be`
  # and you want to reset the method between tests.
  after(:create) do  |user, evaluator|
    restore_original_method(user, :some_method)
    # or
    restore_original_methods(user)
  end
Note

stub_method는 let_it_be_with_refind와 함께 사용하면 동작하지 않습니다. stub_method는 인스턴스의 메서드를 스텁하는데, let_it_be_with_refind는 실행할 때마다 객체의 새 인스턴스를 생성하기 때문입니다.

stub_method는 메서드 존재 여부 확인과 메서드 인수 개수(arity) 확인을 지원하지 않습니다.

Warning

stub_method는 팩토리에서만 사용해야 합니다. 다른 곳에서 사용하는 것은 강력히 권장하지 않습니다. 가능하다면 RSpec mocks를 사용하는 것을 고려합니다.

멤버 액세스 수준 스텁하기#

Project나 Group 같은 팩토리 스텁의 멤버 액세스 수준을 스텁하려면 stub_member_access_level을 사용합니다.

let(:project) { build_stubbed(:project) }
let(:maintainer) { build_stubbed(:user) }
let(:policy) { ProjectPolicy.new(maintainer, project) }

it 'allows admin_project ability' do
  stub_member_access_level(project, maintainer: maintainer)

  expect(policy).to be_allowed(:admin_project)
end
Note

테스트 코드가 project_authorizations 또는 Member 레코드의 영속화에 의존한다면 이 스텁 헬퍼를 사용하지 않습니다. 대신 Project#add_member 또는 Group#add_member를 사용합니다.

추가 프로파일링 지표#

rspec_profiling gem을 사용하면 예를 들어 테스트를 실행할 때 수행되는 SQL 쿼리 수를 진단할 수 있습니다.

이는 테스트 대상이 아닌 부분을 모킹할 수 있는 테스트가 유발한 일부 애플리케이션 측 SQL 쿼리 때문일 수 있습니다(예: !123810).

성능 문서의 안내를 참고합니다.

느린 기능 테스트 문제 해결#

느린 기능 테스트는 일반적으로 다른 테스트와 같은 방식으로 최적화할 수 있습니다. 다만 문제 해결 과정을 더 효과적으로 만들어 주는 몇 가지 구체적인 기법이 있습니다.

기능 테스트가 UI에서 수행하는 동작 확인#
# Before
bin/rspec ./spec/features/admin/admin_settings_spec.rb:992

# After
WEBDRIVER_HEADLESS=0 bin/rspec ./spec/features/admin/admin_settings_spec.rb:992

자세한 내용은 표시되는 브라우저에서 :js 스펙 실행을 참고합니다.

프로파일링할 때 Capybara::DSL# 검색#

stackprof 플레임 그래프를 사용할 때 검색창에 Capybara::DSL#을 검색하면 수행된 Capybara 동작과 각 동작에 걸리는 시간을 확인할 수 있습니다.

느린 테스트 식별#

프로파일링과 함께 스펙을 실행하는 것은 스펙 최적화를 시작하는 좋은 방법입니다. 다음 명령으로 실행할 수 있습니다.

bundle exec rspec --profile -- path/to/spec_file.rb

그러면 다음과 같은 정보가 포함됩니다.

Top 10 slowest examples (10.69 seconds, 7.7% of total time):
  Issue behaves like an editable mentionable creates new cross-reference notes when the mentionable text is edited
    1.62 seconds ./spec/support/shared_examples/models/mentionable_shared_examples.rb:164
  Issue relative positioning behaves like a class that supports relative positioning .move_nulls_to_end manages to move nulls to the end, stacking if we cannot create enough space
    1.39 seconds ./spec/support/shared_examples/models/relative_positioning_shared_examples.rb:88
  Issue relative positioning behaves like a class that supports relative positioning .move_nulls_to_start manages to move nulls to the end, stacking if we cannot create enough space
    1.27 seconds ./spec/support/shared_examples/models/relative_positioning_shared_examples.rb:180
  Issue behaves like an editable mentionable behaves like a mentionable extracts references from its reference property
    0.99253 seconds ./spec/support/shared_examples/models/mentionable_shared_examples.rb:69
  Issue behaves like an editable mentionable behaves like a mentionable creates cross-reference notes
    0.94987 seconds ./spec/support/shared_examples/models/mentionable_shared_examples.rb:101
  Issue behaves like an editable mentionable behaves like a mentionable when there are cached markdown fields sends in cached markdown fields when appropriate
    0.94148 seconds ./spec/support/shared_examples/models/mentionable_shared_examples.rb:86
  Issue behaves like an editable mentionable when there are cached markdown fields when the markdown cache is stale persists the refreshed cache so that it does not have to be refreshed every time
    0.92833 seconds ./spec/support/shared_examples/models/mentionable_shared_examples.rb:153
  Issue behaves like an editable mentionable when there are cached markdown fields refreshes markdown cache if necessary
    0.88153 seconds ./spec/support/shared_examples/models/mentionable_shared_examples.rb:130
  Issue behaves like an editable mentionable behaves like a mentionable generates a descriptive back-reference
    0.86914 seconds ./spec/support/shared_examples/models/mentionable_shared_examples.rb:65
  Issue#related_issues returns only authorized related issues for given user
    0.84242 seconds ./spec/models/issue_spec.rb:335

Finished in 2 minutes 19 seconds (files took 1 minute 4.42 seconds to load)
277 examples, 0 failures, 1 pending

이 결과에서 스펙에서 비용이 가장 큰 예제를 확인할 수 있으며, 이것이 출발점이 됩니다. 여기서 비용이 가장 큰 예제는 공유 예제에 있습니다. 공유 예제는 여러 곳에서 호출되므로 이를 줄이면 대체로 더 큰 효과가 있습니다.

비용이 큰 동작의 반복 피하기#

개별 예제는 매우 명확하고 스펙이 명세 역할을 하는 데 도움이 되지만, 다음 예시는 비용이 큰 동작을 결합하는 방법을 보여 줍니다.

subject { described_class.new(arg_0, arg_1) }

it 'creates an event' do
  expect { subject.execute }.to change(Event, :count).by(1)
end

it 'sets the frobulance' do
  expect { subject.execute }.to change { arg_0.reset.frobulance }.to('wibble')
end

it 'schedules a background job' do
  expect(BackgroundJob).to receive(:perform_async)

  subject.execute
end

subject.execute 호출의 비용이 크다면, 서로 다른 단언을 하려고 같은 동작을 반복하는 셈입니다. 예제를 결합하면 이러한 반복을 줄일 수 있습니다.

it 'performs the expected side-effects' do
  expect(BackgroundJob).to receive(:perform_async)

  expect { subject.execute }
    .to change(Event, :count).by(1)
    .and change { arg_0.frobulance }.to('wibble')
end

이 방법은 성능 향상을 위해 명확성과 테스트 독립성을 희생하므로 신중하게 사용합니다.

테스트를 결합할 때는 첫 번째 실패만이 아니라 전체 결과를 볼 수 있도록 :aggregate_failures 사용을 고려합니다.

wait_for_requests 또는 wait_for_all_requests를 절대 사용하지 않기#

기능 스펙에서 wait_for_requests 또는 wait_for_all_requests를 사용하지 않습니다. RSpec/AvoidWaitForRequests cop이 두 헬퍼를 모두 금지합니다.

이 헬퍼들은 추적 중인 진행 중인 요청이 없어질 때까지만 기다릴 뿐, 스펙이 필요로 하는 결과를 기다리지 않습니다. 요청 하나가 다른 요청을 유발할 수 있습니다. 두 요청 사이에 폴링이 실행되면, 헬퍼는 페이지가 기대한 상태에 도달하기 전에 반환됩니다. 또한 Vue 재렌더링, 리디렉션, 비동기 후속 쓰기, 브라우저가 시작한 다운로드도 기다리지 않습니다.

.rubocop_todo/rspec/avoid_wait_for_requests.yml에 예외를 추가하거나 cop을 인라인으로 비활성화하지 않습니다. 헬퍼를 페이지별 대기 매처로 교체하거나, 눈에 보이는 결과가 없다면 좁게 지정한 wait_for 조건으로 교체합니다. 대안에 관한 안내는 존재하지 않을 것으로 기대하는 요소를 기다리지 않기, 일시적인 컨트롤이 아니라 안정적인 최종 상태를 단언하기, wait_for로 브라우저 부수 효과 폴링하기를 참고합니다.

존재하지 않을 것으로 기대하는 요소를 기다리지 않기#

Capybara의 쿼리 메서드는 조건이 충족되면 즉시 반환하거나 기본 제한 시간 전체를 기다립니다. 긍정 형태는 요소가 나타날 때까지 기다리고, 부정 형태는 요소가 사라질 때까지 기다립니다. 기대하는 상황에 맞는 형태를 항상 사용해 빠른 경로가 일반적인 경로가 되게 합니다. 예를 들어 숨겨진 링크는 다음과 같이 확인합니다.

# Good: returns immediately when absent
expect(page).to have_no_link('Edit')

# Bad: waits the full timeout
expect(page.has_link?('Edit')).to be(false)

중요: 요소가 렌더링을 마치기 전에 부재 확인이 통과할 수 있습니다. 부재를 확인하기 전에 항상 페이지가 로드되었는지 확인합니다. 예를 들어 긍정 매처 또는 within_* 블록을 사용합니다.

expect(page).to have_testid('search-filter')   # confirm page is loaded
expect(page).to have_no_link('Edit')           # then check absence

다음 상호작용이나 단언 전에 긍정 매처로 페이지가 기대한 상태에 도달했는지 확인합니다. 부재 확인 전에만이 아니라 데이터베이스나 모델 상태를 읽기 전, 그리고 이동하기 전에도 확인합니다. 눈에 보이는 기대 결과(have_content, have_current_path, have_css)를 단언하는 것을 우선합니다.

조건부 로직에서 대기를 건너뛰려면 wait: 0을 사용합니다. 참고: 조건부 로직을 피할 수 없을 때만, 그리고 로드되었음을 이미 확인한 영역 안에서만 사용합니다. 그렇지 않으면 잘못된 답을 얻습니다. 테스트의 조건부 로직은 스펙을 비결정적으로 만듭니다.

within_testid('search-filter') do
  click_link 'Edit' if has_link?('Edit', wait: 0)
end

일시적인 컨트롤이 아니라 안정적인 최종 상태를 단언하기#

비동기 변경을 유발하는 동작 후에는, 방금 상호작용한 컨트롤이 컴포넌트가 다시 렌더링되기 전에 일시적인 loading/비활성화 상태를 거치며 레이블이 바뀌는 경우가 많습니다. 동작이 완료되었음을 확인하려고 그 컨트롤을 단언하면 경합이 발생합니다. 컨트롤이 안정되기 전까지 여러 번 바뀌기 때문입니다.

동기화를 위해 버튼의 레이블이나 disabled 상태를 단언하지 않습니다. 변경이 완전히 처리된 후에만 나타나는 안정적인 최종 상태를 단언합니다. 예를 들면 상태 텍스트나 알림 메시지입니다.

# Bad: races the in-flight mutation. The button passes through a transient
# loading/disabled state and keeps its old label, so these assertions can
# pass before the runnerUpdate mutation re-renders the row.
click_button 'Pause'
expect(page).to have_button 'Resume', disabled: false
expect(page).not_to have_button 'Pause'

click_button 'Resume'
expect(page).to have_button 'Pause', disabled: false
expect(page).not_to have_button 'Resume'

# Good: assert on the stable end-state text that only appears once the
# mutation has resolved. This still exercises the pause/resume round-trip.
expect(page).not_to have_text 'Paused'

click_button 'Pause'
expect(page).to have_text 'Paused'

click_button 'Resume'
expect(page).not_to have_text 'Paused'

모달이나 애니메이션이 있는 다른 컨테이너가 열림 전환을 마칠 때까지 기다린 후에 그 안의 컨트롤과 상호작용합니다. 컴포넌트는 전환 중에 컨트롤을 비활성화할 수 있습니다. 예를 들어 보이는 모달을 기다린 후에 닫기 버튼을 클릭합니다.

expect(page).to have_css('.modal.show')

within('.modal') { click_button 'Close' }

모달 헬퍼는 모달과 상호작용하기를 참고합니다.

두 번 방문하지 말고 let 재정의로 설정 변경하기#

:js 기능 스펙에서 중첩된 컨텍스트는 페이지를 방문하기 전에 실행되어야 하는 서로 다른 설정이 필요한 경우가 많습니다. 예를 들면 로그인한 사용자가 다르거나, 레코드 상태가 다르거나, 기능 플래그 상태가 다른 경우입니다. 이러한 설정을 중첩된 컨텍스트 자체의 before 블록에 추가하고 같은 URL에 visit을 두 번째로 호출하지 않습니다.

추가 visit은 같은 페이지를 다시 로드하므로 스펙이 느려집니다.

또한 페이지를 이미 방문한 후에 sign_in을 호출하면 경합이 발생합니다. sign_in 헬퍼가 Warden.on_next_request를 사용해 세션을 주입하는데, 처음 방문한 페이지에서 아직 진행 중인 백그라운드 요청이 그 주입을 소비할 수 있어 의도한 사용자가 로그인되지 않은 상태로 남기 때문입니다.

잘못된 예:

before do
  sign_in(maintainer)
  visit path
end

context 'when the user is a guest' do
  before do
    guest = create(:user, guest_of: project)
    gitlab_sign_out
    sign_in(guest)
    visit path
  end
end

대신 가장 바깥쪽 before 블록에 sign_in과 visit을 하나씩만 두고, let 정의에서 값을 읽어 설정이 동적으로 바뀌게 합니다. 그러면 중첩된 컨텍스트는 관련된 let만 재정의합니다.

올바른 예:

let(:current_user) { maintainer }

before do
  sign_in(current_user)
  visit path
end

context 'when the user is a guest' do
  let(:current_user) { create(:user, guest_of: project) }
end

이렇게 하면 스펙이 더 빨라지고 로그인 경합을 피할 수 있습니다.

wait_for로 브라우저 부수 효과 폴링하기#

동작이 완료되었음을 확인할 때는 항상 눈에 보이는 UI 결과(have_content, have_current_path, have_css)를 단언하는 것을 우선합니다. wait_for 헬퍼(spec/support/helpers/wait_helpers.rb에 정의됨)는 UI에서 단언할 수 있는 것이 없을 때 최후의 수단으로만 사용합니다. 일부 부수 효과에는 눈에 보이는 신호가 없습니다.

  • XHR이나 fetch 요청이 아닌, 브라우저가 시작한 다운로드.
  • 요소가 아직 상호작용할 수 없어서 재시도해야 하는 상호작용 (예: 페이지가 완전히 로드될 때까지 diff 줄 위에 마우스를 올려도 아무 일도 일어나지 않음).

(여기서도 wait_for_requests를 사용하지 않습니다. 이 헬퍼는 더 이상 사용되지 않으며 새 스펙에서 사용해서는 안 됩니다.)

wait_for는 기본적으로 0.01초마다 폴링합니다. 데이터베이스 기반 조건에서 과도한 부하를 피하려면 더 긴 polling_interval을 사용합니다. 작업에 정말로 더 많은 시간이 필요한 경우에만 max_wait_time을 늘립니다.

wait_for('issues sort preference to be saved',
  max_wait_time: 2 * Capybara.default_max_wait_time, polling_interval: 0.1) do
  user.reload.user_preference.issues_sort == 'updated_desc'
end
# Bad: click_link triggers a browser-initiated download and returns before the
# request is logged, so `artifact_request` can be nil.
requests = inspect_requests { click_link 'Download' }
artifact_request = requests.find { |r| r.url.include?('artifacts/download') }

# Good: poll until the download request has been recorded.
requests = inspect_requests do
  click_link 'Download'

  wait_for('artifact download request') do
    Gitlab::Testing::RequestInspectorMiddleware.requests.any? do |r|
      r.url.include?('artifacts/download')
    end
  end
end
artifact_request = requests.find { |r| r.url.include?('artifacts/download') }

대상이 아직 상호작용할 수 없어서 상호작용을 재시도해야 한다면 전체 시퀀스를 wait_for로 감싸고 Capybara::ElementNotFound를 rescue합니다. 아래 예시에서는 페이지 로드가 끝날 때까지 diff 줄 위에 마우스를 올려도 아무 일도 일어나지 않으므로, 클릭만이 아니라 마우스 올리기도 재시도해야 합니다.

# Bad: hovering an unloaded diff line is a no-op, so the button never appears.
line_holder.hover
line[:num].find('.js-add-diff-note-button').click

# Good: retry the hover until the note form appears and is focused.
wait_for('note form to appear') do
  line_holder.hover
  line[:num].find('.js-add-diff-note-button', wait: 0.2).click
  page.has_field?('note_note', focused: true, wait: 0.2)
rescue Capybara::ElementNotFound
end

요소 값을 읽지 말고 대기 매처 사용하기#

요소에서 직접 값, 텍스트 또는 개수를 읽거나(find(...).value, find(...).text, all(...).count) 페이지에서 읽으면(page.current_url) 바로 그 순간의 상태를 가져옵니다. 페이지가 비동기 업데이트를 아직 렌더링하는 중이라면 이전 값을 읽게 되어 테스트가 잘못된 이유로 실패하거나 통과합니다. 반면 have_* 매처는 기대 조건이 충족될 때까지(또는 대기 시간이 초과될 때까지) 재시도하므로 UI와 경합하지 않고 동기화됩니다.

값을 읽어 비교하지 말고 대기 매처로 단언합니다.

# Bad: reads the field value now and compares; races an in-flight update.
expect(find('#cadence-title').value).to eq(cadence.title)

# Good: waits for the field to have the expected value.
expect(page).to have_field('cadence-title', with: cadence.title)
# Bad: all_by_testid returns as soon as one match exists, then compares count.
expect(all_by_testid('cache-entry-row').count).to eq(cache_entries.size)

# Good: waits for the DOM to have the expected number of matches.
expect(page).to have_selector('[data-testid="cache-entry-row"]', count: cache_entries.size)
# Bad: reads the element's text content at this point in time.
expect(find_by_testid("user-project-count-#{admin.id}").text).to eq('1')

# Good: waits for the element to have the expected content.
within_testid("user-project-count-#{admin.id}") do
  expect(page).to have_content('1')
end

expect(find(selector).visible?).to be(true)를 단언하지 않습니다. find가 이미 보이는 요소를 기다리므로, 이 단언으로는 이후의 업데이트를 검증할 수 없습니다. 필요한 상태에 맞는 대기 매처를 사용합니다.

# Bad: reads text immediately after finding the element.
expect(find('.event-title').text).to eq('joined project GitLab')

# Good: waits for the expected exact text.
expect(page).to have_selector('.event-title', exact_text: 'joined project GitLab')

page.current_url 또는 대기하지 않는 다른 세션 값을 읽기 전에 페이지 상태를 기다립니다. 예를 들어 URL을 읽기 전에 페이지별 요소를 단언합니다.

expect(page).to have_testid('board-card')
expect(CGI.unescape(page.current_url)).to include(CGI.unescape(board_path))

스크립트는 기다리지 않음#

evaluate_script와 execute_script는 한 시점의 브라우저를 읽거나 수정합니다. 비동기 업데이트를 기다리지 않습니다. 스크립트로 상태를 읽기 전에 눈에 보이는 결과를 단언하거나 wait_for를 사용합니다.

관련된 스크립트 값이 같은 브라우저 상태를 나타내야 한다면 별도로 호출하지 말고 evaluate_script 한 번으로 가져옵니다.

wait_for('note to be scrolled into view') do
  page.evaluate_script("document.querySelector('.js-static-panel-inner').scrollTop") > 0
end

panel_scroll_top, note_position_top = page.evaluate_script(<<~JS)
  const panel = document.querySelector('.js-static-panel-inner');
  const note = document.querySelector('#note_1');
  [panel.scrollTop, note.getBoundingClientRect().top + panel.scrollTop]
JS

공유 예제에서 준비 상태 게이트 두기#

여러 스펙이 같은 페이지나 컴포넌트를 사용한다면, 준비 상태 단언을 모든 호출자에서 반복하지 말고 공유 예제에 둡니다. 그러면 이 단언이 모든 사용처의 동기화 계약이 됩니다. 예를 들어 Rapid Diffs는 diff-file-mounted 센티널을 사용해 모든 diff 파일이 마운트될 때까지 기다립니다.

before do
  page.assert_selector('diff-file-mounted', count: diffs.diff_files.size, visible: :all)
end

센티널에 관한 자세한 내용은 Rapid Diffs를 참고합니다.

UI가 아닌 메타데이터로 관리자 모드 진입하기#

enable_admin_mode!(admin, use_ui: true)로 브라우저 UI를 통해 관리자 모드를 조작하면 느리고 경합이 생깁니다. 다음 동작이 실행되기 전에 모드 활성화 요청이 완료되지 않을 수 있기 때문입니다. 대신 :enable_admin_mode RSpec 메타데이터 태그를 사용합니다. 이 태그는 UI 상호작용 없이 세션 수준에서 관리자 모드를 활성화합니다.

# Bad: slow and race-prone.
before do
  enable_admin_mode!(admin, use_ui: true)
end

# Good: activates admin mode without touching the browser.
it 'does something as admin', :enable_admin_mode do
  ...
end

# Or on a describe/context block:
context 'when in admin mode', :enable_admin_mode do
  ...
end

비용이 큰 외부 작업 모킹하기#

실제 외부 프로세스(바이너리 컴파일, Git 명령 실행, 네트워크 호출)를 유발하는 기능 스펙과 통합 스펙은, 테스트 대상 로직이 그 프로세스가 실제일 필요가 없더라도 그 프로세스의 전체 실제 소요 시간 비용을 그대로 떠안습니다.

프로덕션 코드베이스 수정 사례는 다음과 같습니다.

  • 실제 Go 컴파일을 유발하는 스펙은 실행할 때마다 약 3분이 추가되었습니다.
  • 실제 Git 명령을 실행하는 스펙은 예제마다 약 10초가 추가되었습니다.

두 경우 모두 테스트 대상 로직은 이미 단위 테스트로 다루고 있었습니다.

셸 명령을 실행하거나, 컴파일하거나, 외부 서비스를 호출하는 before 블록이나 let 정의를 찾습니다. allow / expect(...).to receive(...) 스텁이나 RSpec 더블을 사용해 대신 현실적인 픽스처를 반환하게 합니다. let_it_be와 호환되는 스텁 방식은 팩토리 안에서 메서드 스텁하기를 참고합니다.

단위 테스트가 이미 외부 작업의 출력을 검증한다면, 상위 수준 스펙에서는 이를 스텁합니다. 외부 프로세스를 유발하는 느린 before(:all) 또는 let_it_be는 컨텍스트의 모든 예제에 걸쳐 그 비용이 배가됩니다.

막혔을 때#

느린 백엔드 스펙을 리팩터링하는 데 도움을 줄 수 있는 사람을 나열하기 위해 backend_testing_performance 도메인 전문성이 있습니다.

도움을 줄 수 있는 사람을 찾으려면 Engineering Projects 페이지에서 backend testing performance를 검색하거나, www-gitlab-org 프로젝트에서 직접 찾아봅니다.

기능 카테고리 메타데이터#

각 RSpec 예제에 기능 카테고리 메타데이터를 설정해야 합니다.

EE 라이선스에 따른 테스트#

컨텍스트/스펙 블록에 if: Gitlab.ee? 또는 unless: Gitlab.ee?를 사용하면 FOSS_ONLY=1로 실행하는지에 따라 테스트를 실행할 수 있습니다.

SaaS에 따른 테스트#

SaaS 전용 기능을 테스트하는 방법에 관한 포괄적인 안내는 SaaS 전용 기능 테스트 가이드를 참고합니다.

커버리지#

simplecov는 코드 테스트 커버리지 보고서를 생성하는 데 사용됩니다. 이 보고서는 CI에서 자동으로 생성되지만 로컬에서 테스트를 실행할 때는 생성되지 않습니다. 사용자의 머신에서 스펙 파일을 실행할 때 부분 보고서를 생성하려면 SIMPLECOV 환경 변수를 설정합니다.

SIMPLECOV=1 bundle exec rspec spec/models/repository_spec.rb

커버리지 보고서는 애플리케이션 루트의 coverage 폴더에 생성되며, 예를 들어 다음과 같이 브라우저에서 열 수 있습니다.

firefox coverage/index.html

커버리지 보고서를 사용해 테스트가 코드의 100%를 다루는지 확인합니다.

뷰 스펙#

spec/views/와 ee/spec/views/의 뷰 스펙은 렌더링된 HTML 출력을 검증합니다. 백엔드 로직이나 데이터베이스 동작을 다시 테스트해서는 안 됩니다.

단언은 have_content, have_css, have_selector, have_link 같은 매처를 사용해 렌더링된 출력을 대상으로 해야 합니다. 내부 Ruby 상태나 뷰 헬퍼 메서드의 반환값을 단언하지 않습니다.

스펙에 영속화된 상태가 정말로 필요한 경우가 아니라면 설정에는 create 대신 build_stubbed를 사용해야 합니다. 인스턴스 변수를 전달할 때는 assign을, 헬퍼 메서드를 스텁할 때는 allow(view).to receive(...)를 사용합니다. 설정은 단언하려는 대상에 비례하게 유지합니다. 인스턴스 변수를 많이 할당하고 헬퍼 여러 개를 스텁하면서 요소 하나만 단언하는 스펙은 뷰의 책임이 너무 많다는 신호입니다.

뷰 스펙에는 다음을 포함하지 않습니다.

  • ActiveRecord::QueryRecorder 또는 exceed_query_limit 단언. 쿼리 성능은 뷰 스펙이 아니라 요청 스펙이나 컨트롤러 스펙에서 다룹니다.
  • receive_message_chain 같은 깊은 서비스 객체 모킹 체인. 뷰에 이런 종류의 스텁이 필요하다면 뷰 자체에 로직이 너무 많이 들어 있는 것입니다.

시스템 / 기능 테스트#

Note

새 시스템 테스트를 작성하기 전에 시스템 테스트 사용에 관한 이 가이드를 고려합니다.

  • 기능 스펙은 spec/features/에 두고, EE 전용 기능이라면 ee/spec/features/에 둡니다. 엔드 투 엔드 스펙은 qa/에 별도로 있습니다.
  • 기능 스펙 이름은 user_changes_password_spec.rb처럼 ROLE_ACTION_spec.rb 형식으로 지정해야 합니다.
  • 성공 경로와 실패 경로를 설명하는 시나리오 제목을 사용합니다.
  • "successfully"처럼 정보를 더하지 않는 시나리오 제목은 피합니다.
  • 기능 제목을 반복하는 시나리오 제목은 피합니다.
  • 데이터베이스에는 필요한 레코드만 생성합니다.
  • 정상 경로와 덜 정상적인 경로 하나씩만 테스트하고 그 이상은 하지 않습니다.
  • 그 밖의 가능한 모든 경로는 단위 테스트나 통합 테스트로 테스트해야 합니다.
  • ActiveRecord 모델의 내부가 아니라 페이지에 표시되는 내용을 테스트합니다. 예를 들어 레코드가 생성되었는지 확인하려면 Model.count가 1 늘었는지가 아니라 속성이 페이지에 표시되는지에 대한 기대 조건을 추가합니다.
  • 테스트가 UI 동작 후에 백엔드나 모델 상태를 단언해야 한다면, 먼저 눈에 보이는 성공 표시(have_content, have_current_path, have_css)를 기다린 후에만 model.reload를 읽습니다. Capybara 동작은 요청이 전달되면 반환되며 요청이 완료되면 반환되는 것이 아니므로, 그 직후에 모델 상태를 읽으면 요청과 경합합니다. 이것이 기능 스펙이 불안정해지는 가장 흔한 원인입니다.
  • DOM 요소를 찾는 것은 괜찮지만, 테스트가 더 취약해지므로 남용하지 않습니다.

UI 테스트#

UI를 테스트할 때는 사용자가 보는 것과 UI와 상호작용하는 방식을 시뮬레이션하는 테스트를 작성합니다. 이는 Capybara의 시맨틱 메서드를 우선 사용하고 ID, 클래스, 속성으로 쿼리하는 것을 피한다는 뜻입니다.

이렇게 테스트하면 다음과 같은 이점이 있습니다.

  • 모든 상호작용 요소에 접근 가능한 이름이 있음을 보장합니다.
  • 더 자연스러운 언어를 사용하므로 가독성이 높아집니다.
  • 사용자에게 보이지 않는 ID, 클래스, 속성으로 쿼리하는 것을 피하므로 덜 취약합니다.

ID, 클래스 이름, data-testid가 아니라 요소의 텍스트 레이블로 쿼리하기를 강력히 권장합니다.

필요하다면 within을 사용해 페이지의 특정 영역 안으로 상호작용 범위를 좁힐 수 있습니다. 범위를 div 같은 요소로 좁히게 될 가능성이 높고 이런 요소에는 대개 레이블이 없으므로, 이 경우에는 data-testid 선택자를 사용해도 됩니다.

be_axe_clean 매처를 사용하면 기능 테스트에서 axe 자동 접근성 테스트를 실행할 수 있습니다.

외부화된 콘텐츠#

RSpec 테스트에서 외부화된 콘텐츠에 대한 기대 조건은 번역과 일치시키기 위해 같은 외부화 메서드를 호출해야 합니다. 예를 들어 Ruby에서는 _ 메서드를 사용해야 합니다.

자세한 내용은 Internationalization for GitLab - Test files (RSpec)를 참고합니다.

동작#

가능하면 아래와 같은 더 구체적인 동작을 사용합니다.

# good
click_button _('Submit review')

click_link _('UI testing docs')

fill_in _('Search projects'), with: 'gitlab' # fill in text input with text

select _('Updated date'), from: 'Sort by' # select an option from a select input

check _('Checkbox label')
uncheck _('Checkbox label')

choose _('Radio input label')

attach_file(_('Attach a file'), '/path/to/file.png')

# bad - interactive elements must have accessible names, so
# we should be able to use one of the specific actions above
find('.group-name', text: group.name).click
find('.js-show-diff-settings').click
find('[data-testid="submit-review"]').click
find('input[type="checkbox"]').click
find('.search').native.send_keys('gitlab')
파인더#

가능하면 아래와 같은 더 구체적인 파인더를 사용합니다.

# good
find_button _('Submit review')
find_button _('Submit review'), disabled: true

find_link _('UI testing docs')
find_link _('UI testing docs'), href: docs_url

find_field _('Search projects')
find_field _('Search projects'), with: 'gitlab' # find the input field with text
find_field _('Search projects'), disabled: true
find_field _('Checkbox label'), checked: true
find_field _('Checkbox label'), unchecked: true

# acceptable when finding a element that is not a button, link, or field
find_by_testid('element')
.first 또는 블록 반복과 함께 all() 사용 피하기

all()은 컬렉션을 반환하지만 선택자를 찾지 못해도 예외를 발생시키지 않으며, Capybara의 스마트 대기의 이점도 얻지 못합니다. 그래서 오류가 나기 쉽고 느립니다.

패턴 1 - all().first는 조용히 실패합니다.

# Avoid: silent no-op if selector not found; slower than find()
all('[data-testid="download-dropdown"]').first do |button|
  button.find_by_testid('base-dropdown-toggle').click
  expect(page).to have_link format, href: uri.to_s
end

# Prefer: find() raises immediately with a clear error message if not found
find('[data-testid="unique-download-dropdown"]') do |button|
  button.find_by_testid('base-dropdown-toggle').click
  expect(page).to have_link format, href: uri.to_s
end

# Even better:
within_testid('unique-download-dropdown') do
  find_by_testid('base-dropdown-toggle').click
end

expect(page).to have_link format, href: uri.to_s

패턴 2 - 블록 반복과 함께 all()을 사용해 하위 선택자로 필터링합니다.

# Avoid: iterates every card, calling has_selector? on each, very slow and not robust
card = all("[data-testid='security-testing-card']").find do |node|
  node.has_selector?('h3', text: title, exact_text: true)
end

# Prefer: single CSS child selector query, then walk up to the parent
card = find("[data-testid='security-testing-card'] h3", text: title, exact_text: true)
          .ancestor("[data-testid='security-testing-card']")

알고 있는 하위 요소의 텍스트로 상위 요소를 찾아야 한다면, CSS 하위 선택자로 하위 요소를 먼저 찾은 다음 .ancestor()를 호출해 위로 올라갑니다.

매처#

가능하면 아래와 같은 더 구체적인 매처를 사용합니다.

# good
expect(page).to have_button _('Submit review')
expect(page).to have_button _('Submit review'), disabled: true
expect(page).to have_button _('Notifications'), class: 'is-checked' # assert the "Notifications" GlToggle is checked

expect(page).to have_link _('UI testing docs')
expect(page).to have_link _('UI testing docs'), href: docs_url # assert the link has an href

expect(page).to have_field _('Search projects')
expect(page).to have_field _('Search projects'), disabled: true
expect(page).to have_field _('Search projects'), with: 'gitlab' # assert the input field has text

expect(page).to have_checked_field _('Checkbox label')
expect(page).to have_unchecked_field _('Radio input label')

expect(page).to have_select _('Sort by')
expect(page).to have_select _('Sort by'), selected: 'Updated date' # assert the option is selected
expect(page).to have_select _('Sort by'), options: ['Updated date', 'Created date', 'Due date'] # assert an exact list of options
expect(page).to have_select _('Sort by'), with_options: ['Created date', 'Due date'] # assert a partial list of options

expect(page).to have_text _('Some paragraph text.')
expect(page).to have_text _('Some paragraph text.'), exact: true # assert exact match

expect(page).to have_current_path 'gitlab/gitlab-test/-/issues'

expect(page).to have_title _('Not Found')

# acceptable when a more specific matcher above is not possible
expect(page).to have_css 'h2', text: 'Issue title'
expect(page).to have_css 'p', text: 'Issue description', exact: true
expect(page).to have_css '[data-testid="weight"]', text: 2
expect(page).to have_css '.atwho-view ul', visible: true
모달과 상호작용하기#

GitLab UI 모달과 상호작용하려면 within_modal 헬퍼를 사용합니다.

include Spec::Support::Helpers::ModalHelpers

within_modal do
  expect(page).to have_link _('UI testing docs')

  fill_in _('Search projects'), with: 'gitlab'

  click_button 'Continue'
end

또한 수락만 하면 되는 확인 모달에는 accept_gl_confirm을 사용할 수 있습니다. window.confirm()을 confirmAction으로 마이그레이션할 때 유용합니다.

include Spec::Support::Helpers::ModalHelpers

accept_gl_confirm do
  click_button 'Delete user'
end

accept_gl_confirm에 기대하는 확인 메시지와 버튼 텍스트를 전달할 수도 있습니다.

include Spec::Support::Helpers::ModalHelpers

accept_gl_confirm('Are you sure you want to delete this user?', button_text: 'Delete') do
  click_button 'Delete user'
end
그 밖의 유용한 메서드#

파인더 메서드로 요소를 가져온 후에는 hover 같은 요소 메서드를 여러 개 호출할 수 있습니다.

Capybara 테스트에는 accept_confirm 같은 세션 메서드도 여러 개 있습니다.

그 밖의 유용한 메서드는 아래와 같습니다.

refresh # refresh the page

send_keys([:shift, 'i']) # press Shift+I keys to go to the Issues dashboard page

current_window.resize_to(1000, 1000) # resize the window

scroll_to(find_field('Comment')) # scroll to an element

spec/support/helpers/ 디렉터리에서 GitLab 커스텀 헬퍼도 여러 개 찾을 수 있습니다.

라이브 디버그#

브라우저 동작을 관찰하면서 Capybara 테스트를 디버그해야 할 때가 있습니다.

스펙에서 live_debug 메서드를 사용하면 Capybara를 일시 중지하고 브라우저에서 웹사이트를 볼 수 있습니다. 현재 페이지가 기본 브라우저에서 자동으로 열립니다. 먼저 로그인해야 할 수 있습니다(현재 사용자의 자격 증명이 터미널에 표시됩니다).

테스트 실행을 재개하려면 아무 키나 누릅니다.

예를 들면 다음과 같습니다.

$ bin/rspec spec/features/auto_deploy_spec.rb:34
Running via Spring preloader in process 8999
Run options: include {:locations=>{"./spec/features/auto_deploy_spec.rb"=>[34]}}

Current example is paused for live debugging
The current user credentials are: user2 / 12345678
Press any key to resume the execution of the example!
Back to the example!
.

Finished in 34.51 seconds (files took 0.76702 seconds to load)
1 example, 0 failures

live_debug는 JavaScript가 활성화된 스펙에서만 작동합니다.

표시되는 브라우저에서 :js 스펙 실행#

다음과 같이 WEBDRIVER_HEADLESS=0으로 스펙을 실행합니다.

WEBDRIVER_HEADLESS=0 bin/rspec some_spec.rb

테스트는 빠르게 완료되지만, 무슨 일이 일어나는지 파악하는 데 도움이 됩니다. live_debug를 WEBDRIVER_HEADLESS=0과 함께 사용하면 열려 있는 브라우저가 일시 중지되며, 페이지를 다시 열지 않습니다. 이를 이용해 요소를 디버그하고 검사할 수 있습니다.

byebug 또는 binding.pry를 추가해 실행을 일시 중지하고 테스트를 단계별로 실행할 수도 있습니다.

스크린샷#

capybara-screenshot gem을 사용해 실패할 때 자동으로 스크린샷을 찍습니다. CI에서는 이 파일을 job 아티팩트로 다운로드할 수 있습니다.

실패한 :js 스펙을 분류할 때는 예외 메시지와 백트레이스를 추적하기 전에 스크린샷을 확인합니다. 실패 시점의 페이지 상태(예상하지 못한 빈 상태, 화면을 가리는 모달, 이전 예제에서 남은 오래된 페이지)가 실제 원인인 경우가 많고, 예외 텍스트만으로는 관련 없는 세부 사항으로 이끌릴 수 있습니다. 이를 돕기 위해 스크린샷 경로는 놓칠 수 있는 별도 단계가 아니라 실패 자체의 Failure/Error: 메시지 바로 아래에 출력됩니다.

또한 아래 메서드를 추가해 테스트의 어느 지점에서든 직접 스크린샷을 찍을 수 있습니다. 더 이상 필요하지 않으면 반드시 제거합니다. 자세한 내용은 https://github.com/mattheworiordan/capybara-screenshot#manual-screenshots를 참고합니다.

:js 스펙에 screenshot_and_save_page를 추가하면 Capybara가 "보는" 화면의 스크린샷을 찍고 페이지 소스를 저장합니다.

:js 스펙에 screenshot_and_open_image를 추가하면 Capybara가 "보는" 화면의 스크린샷을 찍고 이미지를 자동으로 엽니다.

이렇게 생성된 HTML 덤프에는 CSS가 없습니다. 그래서 실제 애플리케이션과 매우 다르게 보입니다. 디버깅을 더 쉽게 해 주는 CSS를 추가하는 간단한 방법이 있습니다.

빠른 단위 테스트#

일부 클래스는 Rails와 잘 분리되어 있습니다. 이런 클래스는 Rails 환경과 Bundler의 :default 그룹 gem 로딩으로 인한 오버헤드 없이 테스트할 수 있어야 합니다. 이 경우 테스트 파일에서 require 'spec_helper' 대신 require 'fast_spec_helper'를 사용할 수 있으며, 다음 이유로 테스트가 매우 빠르게 실행됩니다.

  • gem 로딩을 건너뜁니다.
  • Rails 앱 부팅을 건너뜁니다.
  • GitLab Shell과 Gitaly 설정을 건너뜁니다.
  • 테스트 리포지터리 설정을 건너뜁니다.

fast_spec_helper를 사용하는 테스트는 로드하는 데 약 1초가 걸리며, 일반 spec_helper는 30초 이상 걸립니다.

fast_spec_helper는 lib/ 디렉터리 안에 있는 클래스의 자동 로딩도 지원합니다. 클래스나 모듈이 lib/ 디렉터리의 코드만 사용한다면 의존성을 명시적으로 로드할 필요가 없습니다. fast_spec_helper는 Rails 환경에서 흔히 사용하는 코어 확장을 포함해 모든 ActiveSupport 확장도 로드합니다.

경우에 따라 코드가 gem을 사용하거나 의존성이 lib/에 없으면 require_dependency로 일부 의존성을 로드해야 할 수도 있습니다.

예를 들어 내부적으로 re2 라이브러리를 사용하는 Gitlab::UntrustedRegexp 클래스를 호출하는 코드를 테스트하려면 다음 중 하나를 해야 합니다.

  • re2 gem이 필요한 라이브러리 파일에 require_dependency 're2'를 추가해 이 요구 사항을 명시적으로 드러냅니다. 이 방법을 권장합니다.
  • 스펙 자체에 추가합니다.

또는 도메인의 여러 fast_spec_helper 스펙에서 필요한 의존성이고 수동으로 여러 번 추가하고 싶지 않다면, fast_spec_helper 자체에서 직접 호출되도록 추가할 수 있습니다. 이를 위해 spec/support/fast_spec/YOUR_DOMAIN/fast_spec_helper_support.rb 파일을 만들고 fast_spec_helper에서 require합니다. 따라 할 수 있는 기존 예시가 있습니다.

RuboCop 관련 스펙에는 rubocop_spec_helper를 사용합니다.

Warning

코드와 스펙이 Rails와 잘 분리되어 있는지 확인하려면 bin/rspec으로 스펙을 개별 실행합니다. bin/spring rspec은 spec_helper를 자동으로 로드하므로 사용하지 않습니다.

fast_spec_helper 스펙 유지 관리#

fast_spec_helper를 사용하는 모든 스펙을 여러 방식으로 실행하는 데 사용할 수 있는 유틸리티 스크립트 scripts/run-fast-specs.sh가 있습니다. 이 스크립트는 격리된 상태에서 성공적으로 실행되지 않는 등 문제가 있는 fast_spec_helper 스펙을 식별하는 데 유용합니다. 자세한 내용은 스크립트를 참고합니다.

subject와 let 변수#

GitLab RSpec 스위트는 중복을 줄이기 위해 let(그리고 그 엄격한 비지연 버전인 let!) 변수를 광범위하게 사용해 왔습니다. 그러나 이는 때때로 명확성을 대가로 치르므로, 앞으로 사용에 관한 몇 가지 지침을 정해야 합니다.

  • 컨텍스트마다 let 정의를 반복하는 대신 표 기반 / 매개변수화 테스트를 우선 사용합니다.
  • let! 변수는 인스턴스 변수보다 낫습니다. let 변수는 let! 변수보다 낫습니다. 로컬 변수는 let 변수보다 낫습니다.
  • 스펙 파일 전체의 중복을 줄이려면 let을 사용합니다.
  • 단일 테스트에서만 사용하는 변수를 정의하려고 let을 사용하지 않습니다. 테스트의 it 블록 안에 로컬 변수로 정의합니다.
  • 더 깊이 중첩된 context 또는 describe 블록에서만 사용하는 let 변수를 최상위 describe 블록 안에 정의하지 않습니다. 정의를 사용하는 곳과 가능한 한 가깝게 둡니다.
  • 한 let 변수의 정의를 다른 let 변수로 재정의하는 것을 피합니다.
  • 다른 let 변수의 정의에서만 사용하는 let 변수를 정의하지 않습니다. 대신 헬퍼 메서드를 사용합니다.
  • let! 변수는 정해진 순서로 엄격하게 평가해야 하는 경우에만 사용해야 하며, 그렇지 않으면 let으로 충분합니다. let은 지연 평가되며 참조되기 전까지는 평가되지 않는다는 점을 기억합니다.
  • 예제에서 subject를 참조하는 것을 피합니다. 변수가 문맥에 맞는 이름을 갖도록 이름 있는 subject subject(:name) 또는 let 변수를 대신 사용합니다.

표 기반 / 매개변수화 테스트#

이 방식의 테스트는 하나의 코드를 포괄적인 범위의 입력으로 실행하는 데 사용합니다. 테스트 케이스를 한 번만 지정하고 입력 표와 각 입력에 대한 기대 출력을 함께 두면, 테스트를 더 읽기 쉽고 더 간결하게 만들 수 있습니다.

RSpec::Parameterized gem을 사용합니다.

let 값만 다른 여러 context 블록보다 표 기반 테스트를 우선 사용합니다. 예를 들어 다음과 같이 컨텍스트를 반복하는 대신

# bad
context 'when the group status is :active' do
  let(:status) 
  let(:actor) { create(:group) }

  it { expect(actor.visible?).to be(true) }
end

context 'when the project status is :active' do
  let(:status) 
  let(:actor) { create(:project) }

  it { expect(actor.visible?).to be(true) }
end

context 'when the group status is :inactive' do
  let(:status) 
  let(:actor) { create(:group) }

  it { expect(actor.visible?).to be(false) }
end

context 'when the project status is :inactive' do
  let(:status) 
  let(:actor) { create(:project) }

  it { expect(actor.visible?).to be(false) }
end

표를 사용하면 같은 케이스를 더 간결하게 표현할 수 있습니다.

# good
using RSpec::Parameterized::TableSyntax

let(:group) { create(:group) }
let(:project) { create(:project) }

where(:actor, :status, :visible) do
  ref(:group) | :active   | true
  ref(:group) | :inactive | false
  ref(:project) | :active   | true
  ref(:project) | :inactive | false
end

with_them do
  it { expect(actor.visible?).to be(visible) }
end

표 기반 테스트를 만든 후 다음과 같은 오류가 표시된다면

NoMethodError:
  undefined method `to_params'

  param_sets = extracted.is_a?(Array) ? extracted : extracted.to_params
                                                                       ^^^^^^^^^^
  Did you mean?  to_param

스펙 파일에 using RSpec::Parameterized::TableSyntax 줄을 포함해야 한다는 뜻입니다.

Warning

where 블록의 입력에는 단순한 값만 사용합니다. proc, 상태를 가진 객체, FactoryBot으로 생성한 객체 등을 사용하면 예기치 않은 결과가 발생할 수 있습니다. 대신 ref(:symbol)을 사용합니다.

공통 테스트 설정#

Note

let_it_be와 before_all은 DatabaseCleaner의 삭제 전략과 함께 작동하지 않습니다. 여기에는 마이그레이션 스펙, Rake 태스크 스펙, :delete RSpec 메타데이터 태그가 있는 스펙이 포함됩니다. 자세한 내용은 이슈 420379를 참고합니다.

경우에 따라 예제마다 테스트용 동일한 객체를 다시 생성할 필요가 없습니다. 예를 들어 같은 프로젝트의 이슈를 테스트하려면 프로젝트와 그 프로젝트의 게스트가 필요하므로, 파일 전체에 프로젝트와 사용자 하나씩이면 충분합니다.

가능하면 before(:all) 또는 before(:context)로 이를 구현하지 않습니다. 그렇게 하면 이 훅은 데이터베이스 트랜잭션 밖에서 실행되므로 데이터를 수동으로 정리해야 합니다.

대신 test-prof gem의 let_it_be 변수와 before_all 훅을 사용해 이를 달성할 수 있습니다.

let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) }

before_all do
  project.add_guest(user)
end

그러면 이 컨텍스트에서 Project, User, ProjectMember가 각각 하나만 생성됩니다.

let_it_be와 before_all은 중첩된 컨텍스트에서도 사용할 수 있습니다. 컨텍스트가 끝난 후의 정리는 트랜잭션 롤백으로 자동 처리됩니다.

let_it_be 블록 안에 정의된 객체를 수정하는 경우 다음 중 하나를 해야 합니다.

  • 필요에 따라 객체를 다시 로드합니다.
  • let_it_be_with_reload 별칭을 사용합니다.
  • reload 옵션을 지정해 모든 예제마다 다시 로드합니다.
let_it_be_with_reload(:project) { create(:project) }
let_it_be(:project, reload: true) { create(:project) }

let_it_be_with_refind 별칭을 사용하거나 refind 옵션을 지정해 새 객체를 완전히 로드할 수도 있습니다.

let_it_be_with_refind(:project) { create(:project) }
let_it_be(:project, refind: true) { create(:project) }

let_it_be는 allow 같은 스텁이 있는 팩토리와 함께 사용할 수 없습니다. let_it_be는 before(:all) 블록에서 실행되고, RSpec은 before(:all)에서 스텁을 허용하지 않기 때문입니다. 자세한 내용은 이 이슈를 참고합니다. 해결하려면 let을 사용하거나 팩토리가 스텁을 사용하지 않도록 변경합니다.

let_it_be는 before 블록에 의존해서는 안 됨#

스펙 중간에서 let_it_be를 사용할 때는 before 블록에 의존하지 않도록 합니다. let_it_be가 before(:all) 중에 먼저 실행되기 때문입니다.

이 예시에서 create(:bar)가 스텁에 의존하는 콜백을 실행했습니다.

let_it_be(:node) { create(:geo_node, :secondary) }

before do
  stub_current_geo_node(node)
end

context 'foo' do
  let_it_be(:bar) { create(:bar) }

  ...
end

create(:bar)가 실행될 때 스텁이 설정되어 있지 않으므로 테스트가 불안정합니다.

이 예시에서는 테스트별 수명 주기 밖에서 RSpec-mocks의 더블이나 부분 더블을 사용할 수 없으므로 before를 before_all로 대체할 수 없습니다.

따라서 let_it_be(:bar) 대신 let 또는 let!을 사용하는 것이 해결책입니다.

시간에 민감한 테스트#

ActiveSupport::Testing::TimeHelpers를 사용하면 시간에 민감한 항목을 검증할 수 있습니다. 시간에 민감한 항목을 실행하거나 검증하는 모든 테스트는 일시적인 테스트 실패를 방지하기 위해 이 헬퍼를 사용해야 합니다.

피해야 할 시간 관련 불안정성의 반복적인 원인 두 가지는 다음과 같습니다.

  • 테스트가 타임스탬프 칼럼으로 레코드를 정렬한다면 레코드마다 서로 다른 타임스탬프를 부여합니다(예: travel_to와 명시적 오프셋 사용). 타임스탬프가 같으면 정렬이 비결정적이 되어 단언이 불안정해집니다.
  • "오늘"을 기준으로 동작이 결정되는 스펙에 미래 날짜 상수를 하드코딩하지 않습니다. 그 날짜가 오기 전까지는 통과하다가 그 이후에는 실패합니다. travel_to를 사용하거나 Time.current를 기준으로 날짜를 계산합니다.

예시:

it 'is overdue' do
  issue = build(:issue, due_date: Date.tomorrow)

  travel_to(3.days.from_now) do
    expect(issue).to be_overdue
  end
end

RSpec 헬퍼#

:freeze_time과 :time_travel_to RSpec 메타데이터 태그 헬퍼를 사용하면 스펙 전체를 ActiveSupport::Testing::TimeHelpers 메서드로 감싸는 데 필요한 상용구 코드의 양을 줄일 수 있습니다.

describe 'specs which require time to be frozen', :freeze_time do
  it 'freezes time' do
    right_now = Time.now

    expect(Time.now).to eq(right_now)
  end
end

describe 'specs which require time to be frozen to a specific date and/or time', time_travel_to: '2020-02-02 10:30:45 -0700' do
  it 'freezes time to the specified date and time' do
    expect(Time.now).to eq(Time.new(2020, 2, 2, 17, 30, 45, '+00:00'))
  end
end

내부적으로 이 헬퍼들은 around(:each) 훅과 ActiveSupport::Testing::TimeHelpers 메서드의 블록 구문을 사용합니다.

around(:each) do |example|
  freeze_time { example.run }
end

around(:each) do |example|
  travel_to(date_or_time) { example.run }
end

예제가 실행되기 전에 생성된 객체(예: let_it_be로 생성한 객체)는 스펙 범위 밖에 있다는 점을 기억합니다. 모든 것의 시간을 동결해야 한다면 before :all을 사용해 설정까지 감쌀 수 있습니다.

before :all do
  freeze_time
end

after :all do
  unfreeze_time
end

타임스탬프 절삭#

Active Record 타임스탬프는 Rails의 ActiveRecord::Timestamp 모듈이 Time.now를 사용해 설정합니다. 시간 정밀도는 OS에 따라 다르며, 문서에 명시된 대로 소수 초를 포함할 수 있습니다.

Rails 모델이 데이터베이스에 저장될 때 모델의 타임스탬프는 PostgreSQL의 timestamp without time zone이라는 타입으로 저장되며, 이 타입은 마이크로초 해상도(소수점 이하 여섯 자리)를 가집니다. 따라서 1577987974.6472975를 PostgreSQL에 보내면 소수 부분의 마지막 자리를 잘라내고 대신 1577987974.647297을 저장합니다.

그 결과 다음과 같은 단순한 테스트가

let_it_be(:contact) { create(:contact) }

data = Gitlab::HookData::IssueBuilder.new(issue).build

expect(data).to include('customer_relations_contacts' => [contact.hook_attrs])

다음과 비슷한 오류로 실패할 수 있습니다.

expected {
"assignee_id" => nil, "...1 +0000 } to include {"customer_relations_contacts" => [{:created_at => "2023-08-04T13:30:20Z", :first_name => "Sidney Jones3" }]}

Diff:
       @@ -1,35 +1,69 @@
       -"customer_relations_contacts" => [{:created_at=>"2023-08-04T13:30:20Z", :first_name=>"Sidney Jones3" }],
       +"customer_relations_contacts" => [{"created_at"=>2023-08-04 13:30:20.245964000 +0000, "first_name"=>"Sidney Jones3" }],

해결 방법은 데이터베이스에서 객체를 .reload해 올바른 정밀도의 타임스탬프를 가져오는 것입니다.

let_it_be(:contact) { create(:contact) }

data = Gitlab::HookData::IssueBuilder.new(issue).build

expect(data).to include('customer_relations_contacts' => [contact.reload.hook_attrs])

이 설명은 Maciek Rząsa의 블로그 글에서 가져왔습니다.

이 문제가 발생한 머지 리퀘스트와 이를 논의한 백엔드 페어링 세션을 확인할 수 있습니다.

테스트의 기능 플래그#

이 섹션은 기능 플래그를 사용한 개발로 이동했습니다.

깨끗한 테스트 환경#

단일 GitLab 테스트가 실행하는 코드는 많은 데이터 항목에 접근하고 수정할 수 있습니다. 테스트가 실행되기 전의 신중한 준비와 이후의 정리가 없으면, 테스트가 이후 테스트의 동작에 영향을 주는 방식으로 데이터를 변경할 수 있습니다. 이는 어떤 경우에도 피해야 합니다. 다행히 기존 테스트 프레임워크가 대부분의 경우를 이미 처리합니다.

테스트 환경이 오염되면 흔한 결과는 불안정한 테스트입니다. 오염은 흔히 순서 의존성으로 나타납니다. 스펙 A 다음에 스펙 B를 실행하면 항상 실패하지만, 스펙 B 다음에 스펙 A를 실행하면 항상 성공합니다. 이런 경우 rspec --bisect(또는 스펙 파일의 수동 쌍별 이분 탐색)를 사용해 어느 스펙이 원인인지 확인할 수 있습니다. 문제를 해결하려면 테스트 스위트가 환경을 깨끗하게 유지하는 방법을 어느 정도 이해해야 합니다. 각 데이터 저장소에 관해 더 알아봅니다.

SQL 데이터베이스#

database_cleaner gem이 이를 관리합니다. 각 스펙은 트랜잭션으로 감싸지며, 테스트가 완료된 후 롤백됩니다. 일부 스펙은 대신 완료 후 모든 테이블에 DELETE FROM 쿼리를 실행합니다. 그러면 생성된 행을 여러 데이터베이스 연결에서 볼 수 있으며, 이는 브라우저에서 실행되는 스펙이나 마이그레이션 스펙 등에 중요합니다.

널리 알려진 TRUNCATE TABLES 방식 대신 이러한 전략을 사용하면 기본 키와 그 밖의 시퀀스가 스펙 간에 초기화되지 않는다는 결과가 따릅니다. 따라서 스펙 A에서 프로젝트를 생성한 다음 스펙 B에서 프로젝트를 생성하면, 첫 번째는 id=1이고 두 번째는 id=2입니다.

즉, 스펙은 ID 값이나 그 밖의 시퀀스로 생성되는 칼럼 값에 의존해서는 안 됩니다. 의도하지 않은 충돌을 피하기 위해 스펙은 이런 종류의 칼럼에 값을 수동으로 지정하는 것도 피해야 합니다. 대신 값을 지정하지 않고 두었다가 행이 생성된 후에 값을 조회합니다.

마이그레이션 스펙의 TestProf#

위에서 설명한 이유로 마이그레이션 스펙은 데이터베이스 트랜잭션 안에서 실행할 수 없습니다. 테스트 스위트는 TestProf를 사용해 테스트 스위트의 실행 시간을 개선하지만, TestProf는 이러한 최적화를 수행하려고 데이터베이스 트랜잭션을 사용합니다. 이 때문에 마이그레이션 스펙에서는 TestProf 메서드를 사용할 수 없습니다. 다음은 사용하지 말고 기본 RSpec 메서드로 대체해야 하는 메서드입니다.

  • let_it_be: 대신 let 또는 let!을 사용합니다.
  • let_it_be_with_reload: 대신 let 또는 let!을 사용합니다.
  • let_it_be_with_refind: 대신 let 또는 let!을 사용합니다.
  • before_all: 대신 before 또는 before(:all)을 사용합니다.

Workhorse JWT 검증#

Senddata를 내보내는 Rails 컨트롤러와 Grape API 엔드포인트는 verify_workhorse_api!를 호출해 Workhorse를 거치지 않은 요청을 거부합니다. 표준 강제 지점은 응답을 쓰는 헬퍼 자체입니다 (Rails 컨트롤러는 app/helpers/workhorse_helper.rb, Grape는 lib/api/helpers.rb이며 send_git_blob, send_git_archive, send_artifacts_entry, present_carrierwave_file!, send_workhorse_headers! 등이 있습니다). 모든 송출은 이 헬퍼 중 하나를 거치며, 헬퍼는 Gitlab-Workhorse-Send-Data 응답 헤더를 저장하기 전에 JWT를 검증합니다.

spec/support/workhorse_jwt_injection.rb의 테스트 측 메커니즘 두 가지가 CI에서 이 계약을 실행합니다.

  1. before 훅이 모든 테스트 요청에 유효한 Workhorse JWT를 주입합니다. 실제 Gitlab::Workhorse.verify_api_request!가 테스트 프로세스에서 실행되며 스텁은 없고, 정상 경로 스펙에서는 JWT 헤더를 구성할 필요가 없습니다.
  2. after 훅은 Gitlab-Workhorse-Send-Data가 송출되었지만 요청에 검증된 JWT가 없었다면 예제를 실패시킵니다. 보호된 헬퍼를 우회하는 향후 엔드포인트는 조용히 통과하는 대신 자체 정상 경로 테스트가 실패하게 됩니다.

테스트가 강제 경계 자체를 단언할 때만, 예를 들어 헤더가 없을 때 403 Forbidden 응답을 단언할 때만 예제나 컨텍스트에 :verify_workhorse_jwt 태그를 붙입니다. 이 태그는 주입과 감지를 모두 해제합니다.

context 'without Workhorse JWT', :verify_workhorse_jwt do
  it 'returns 403 forbidden' do
    get api(path)

    expect(response).to have_gitlab_http_status(:forbidden)
  end
end

일반 테스트에는 이 태그를 적용하지 않습니다. 자동 주입이 일반 테스트를 다루며, 이 태그는 테스트가 JWT가 없는 경로를 명시적으로 처리하도록 강제합니다.

Redis#

GitLab은 캐시된 항목과 Sidekiq job이라는 두 가지 주요 범주의 데이터를 Redis에 저장합니다. 별도의 Redis 인스턴스가 뒷받침하는 Gitlab::Redis::Wrapper 하위 클래스의 전체 목록을 확인합니다.

대부분의 스펙에서 Rails 캐시는 실제로 인메모리 저장소입니다. 이 저장소는 스펙 사이에 교체되므로 Rails.cache.read와 Rails.cache.write 호출은 안전합니다. 그러나 스펙이 Redis를 직접 호출한다면 어느 Redis 인스턴스를 사용하는지에 따라 :clean_gitlab_redis_cache, :clean_gitlab_redis_shared_state 또는 :clean_gitlab_redis_queues 트레이트를 스스로 표시해야 합니다.

백그라운드 job / Sidekiq#

기본적으로 Sidekiq job은 job 배열에 큐잉되며 처리되지 않습니다. 테스트가 Sidekiq job을 큐에 넣고 이를 처리해야 한다면 :sidekiq_inline 트레이트를 사용할 수 있습니다.

:sidekiq_might_not_need_inline 트레이트는 Sidekiq 인라인 모드가 페이크 모드로 변경되었을 때 Sidekiq이 실제로 job을 처리해야 했던 모든 테스트에 추가되었습니다. 이 트레이트가 있는 테스트는 Sidekiq의 job 처리에 의존하지 않도록 수정하거나, 백그라운드 job 처리가 필요하거나 기대된다면 :sidekiq_might_not_need_inline 트레이트를 :sidekiq_inline으로 업데이트해야 합니다.

perform_enqueued_jobs는 지연된 메일 전달을 테스트할 때만 유용합니다. Sidekiq 워커가 ApplicationJob / ActiveJob::Base를 상속하지 않기 때문입니다.

기능 스펙에서는 UI 동작을 유발하는 코드와 그 눈에 보이는 결과(메일 전달, 확인 모달, 업데이트된 페이지 상태)에 대한 단언을 모두 perform_enqueued_jobs 블록 안에 감쌉니다. 클릭은 나중에 job을 큐에 넣는 AJAX 요청만 시작합니다. 블록이 클릭만 감싸면 그 요청이 job을 큐에 넣기 전에 블록이 끝날 수 있으며, 그러면 job이 인라인이 아니라 기본 테스트 어댑터를 거쳐 실행되어 처리되지 않습니다. 블록 안에서 결과를 단언하면 요청이 완료되고 job이 인라인으로 실행될 때까지 블록이 열려 있게 됩니다.

# Bad: the email is not yet delivered when the assertion runs.
perform_enqueued_jobs { click_button 'Approve' }
expect(page).to have_content('Approval email sent')

# Good: the assertion runs only after the enqueued jobs complete.
perform_enqueued_jobs do
  click_button 'Approve'
  expect(page).to have_content('Approval email sent')
end

DNS#

DNS는 개발자의 로컬 네트워크에 따라 문제를 일으킬 수 있으므로 DNS 요청은 테스트 스위트 전체에서 스텁 처리됩니다 (!22368 기준). spec/support/dns.rb에 RSpec 레이블이 있으며, DNS 스텁을 우회해야 할 때 다음과 같이 테스트에 적용할 수 있습니다.

it "really connects to Prometheus", :permit_dns do

더 구체적인 제어가 필요하다면 DNS 차단은 spec/support/helpers/dns_helpers.rb에 구현되어 있으며 이 메서드들을 다른 곳에서 호출할 수 있습니다.

속도 제한#

속도 제한은 테스트 스위트에서 활성화되어 있습니다. :js 트레이트를 사용하는 기능 스펙에서 속도 제한이 발동될 수 있습니다. 대부분의 경우 스펙에 :clean_gitlab_redis_rate_limiting 트레이트를 표시하면 속도 제한 발동을 피할 수 있습니다. 이 트레이트는 스펙 사이에 Redis 캐시에 저장된 속도 제한 데이터를 지웁니다. 단일 테스트가 속도 제한을 발동시킨다면 대신 :disable_rate_limit을 사용할 수 있습니다.

File 메서드 스텁하기#

파일의 내용을 스텁해야 하는 상황에서는 File.read 스텁을 올바르게 처리하는 stub_file_read와 expect_file_read 헬퍼 메서드를 사용합니다. 이 메서드들은 지정한 파일 이름에 대해 File.read를 스텁하고, File.exist?도 true를 반환하도록 스텁합니다.

어떤 이유로든 File.read를 수동으로 스텁해야 한다면 다음을 반드시 지킵니다.

  1. 다른 파일 경로에 대해서는 원래 구현을 스텁하고 호출합니다.
  2. 그런 다음 관심 있는 파일 경로에 대해서만 File.read를 스텁합니다.

그렇지 않으면 코드베이스의 다른 부분에서 호출하는 File.read가 잘못 스텁됩니다.

# bad, all Files will read and return nothing
allow(File).to receive(:read)

# good
stub_file_read(my_filepath, content: "fake file content")

# also OK
allow(File).to receive(:read).and_call_original
allow(File).to receive(:read).with(my_filepath).and_return("fake file_content")

파일 시스템#

파일 시스템 데이터는 대략 "리포지터리"와 "그 밖의 모든 것"으로 나눌 수 있습니다. 리포지터리는 tmp/tests/repositories에 저장됩니다. 이 디렉터리는 테스트 실행이 시작되기 전과 끝난 후에 비워집니다. 스펙 사이에는 비워지지 않으므로, 생성된 리포지터리가 프로세스가 실행되는 동안 이 디렉터리에 누적됩니다. 삭제하는 데 비용이 많이 들지만, 신중하게 관리하지 않으면 오염으로 이어질 수 있습니다.

이를 피하기 위해 테스트 스위트에서는 해시 스토리지가 활성화되어 있습니다. 즉, 리포지터리에는 프로젝트 ID에 따라 결정되는 고유한 경로가 부여됩니다. 프로젝트 ID는 스펙 사이에 초기화되지 않으므로 각 스펙은 디스크에 자체 리포지터리를 갖게 되고, 스펙 사이에 변경 사항이 보이지 않게 됩니다.

스펙이 프로젝트 ID를 수동으로 지정하거나 tmp/tests/repositories/ 디렉터리의 상태를 직접 검사한다면, 실행 전과 후에 해당 디렉터리를 정리해야 합니다. 일반적으로 이러한 패턴은 완전히 피해야 합니다.

업로드처럼 데이터베이스 객체에 연결된 그 밖의 파일 종류도 일반적으로 같은 방식으로 관리됩니다. 스펙에서 해시 스토리지가 활성화되어 있으면 ID에 따라 결정되는 위치에 디스크로 기록되므로 충돌이 발생하지 않아야 합니다.

일부 스펙은 projects 팩토리에 :legacy_storage 트레이트를 전달해 해시 스토리지를 비활성화합니다. 이렇게 하는 스펙은 프로젝트나 해당 그룹의 path를 절대 재정의해서는 안 됩니다. 기본 경로에는 프로젝트 ID가 포함되므로 충돌하지 않습니다. 두 스펙이 같은 경로로 :legacy_storage 프로젝트를 생성하면 디스크의 같은 리포지터리를 사용하게 되어 테스트 환경 오염으로 이어집니다.

그 밖의 파일은 스펙이 직접 관리해야 합니다. 예를 들어 tmp/test-file.csv 파일을 생성하는 코드를 실행한다면, 스펙은 정리 과정에서 해당 파일이 제거되도록 해야 합니다.

지속되는 인메모리 애플리케이션 상태#

주어진 rspec 실행의 모든 스펙은 같은 Ruby 프로세스를 공유하므로, 스펙 사이에 접근할 수 있는 Ruby 객체를 수정해 서로 영향을 줄 수 있습니다. 실제로는 전역 변수와 상수(Ruby 클래스, 모듈 등 포함)가 이에 해당합니다.

전역 변수는 일반적으로 수정하지 않아야 합니다. 꼭 필요하다면 다음과 같은 블록을 사용해 이후 변경이 롤백되도록 할 수 있습니다.

around(:each) do |example|
  old_value = $0

  begin
    $0 = "new-value"
    example.run
  ensure
    $0 = old_value
  end
end

스펙이 상수를 수정해야 한다면 stub_const 헬퍼를 사용해 변경이 롤백되도록 해야 합니다.

ENV 상수의 내용을 수정해야 한다면 대신 stub_env 헬퍼 메서드를 사용할 수 있습니다.

대부분의 Ruby 인스턴스는 스펙 사이에 공유되지 않지만 클래스와 모듈은 일반적으로 공유됩니다. 클래스와 모듈의 인스턴스 변수, 접근자, 클래스 변수, 그 밖의 상태를 가진 관용구는 전역 변수와 같은 방식으로 다뤄야 합니다. 꼭 필요한 경우가 아니면 수정하지 않습니다. 특히 수정이 필요하지 않도록 기대 조건 또는 스텁과 함께 의존성 주입을 사용하는 것을 우선합니다. 다른 선택지가 없다면 전역 변수 예시와 같은 around 블록을 사용할 수 있지만, 가능하면 피합니다.

Elasticsearch 스펙#

Elasticsearch가 필요한 스펙에는 :elastic 또는 :elastic_delete_by_query 메타데이터를 표시해야 합니다. :elastic 메타데이터는 모든 예제 전후에 인덱스를 생성하고 삭제합니다.

:elastic_delete_by_query 메타데이터는 각 컨텍스트의 시작과 끝에서만 인덱스를 생성하고 삭제해 파이프라인 실행 시간을 줄이기 위해 추가되었습니다. 인덱스를 깨끗하게 유지하도록 예제 사이에 모든 인덱스(마이그레이션 인덱스 제외)의 데이터를 삭제하는 데 Elasticsearch delete by query API를 사용합니다.

:elastic_clean 메타데이터는 인덱스를 깨끗하게 유지하도록 예제 사이에 인덱스를 생성하고 삭제합니다. 이렇게 하면 테스트가 필수적이지 않은 데이터로 오염되지 않습니다. :elastic 또는 :elastic_delete_by_query 메타데이터를 사용할 때 문제가 발생한다면 대신 :elastic_clean을 사용합니다. :elastic_clean은 다른 트레이트보다 훨씬 느리므로 드물게 사용해야 합니다.

Elasticsearch 로직에 대한 대부분의 테스트는 다음과 관련이 있습니다.

  • PostgreSQL에 데이터를 생성하고 Elasticsearch에 인덱싱되기를 기다립니다.
  • 해당 데이터를 검색합니다.
  • 테스트가 기대한 결과를 내는지 확인합니다.

인덱스의 개별 레코드가 아니라 구조적 변경을 확인하는 경우처럼 몇 가지 예외가 있습니다.

Note

고급 검색의 인덱싱은 Gitlab::Redis::SharedState를 사용합니다. 따라서 Elasticsearch 메타데이터는 동적으로 :clean_gitlab_redis_shared_state를 사용합니다. :clean_gitlab_redis_shared_state를 수동으로 추가할 필요가 없습니다.

Elasticsearch를 사용하는 스펙에서는 다음을 해야 합니다.

  • PostgreSQL에 데이터를 생성한 다음 Elasticsearch로 인덱싱합니다.
  • Elasticsearch의 애플리케이션 설정을 활성화합니다(기본적으로 비활성화되어 있음).

이를 위해 다음을 사용합니다.

before do
  stub_ee_application_setting(elasticsearch_search: true, elasticsearch_indexing: true)
end

또한 ensure_elasticsearch_index! 메서드를 사용하면 Elasticsearch의 비동기적 특성을 극복할 수 있습니다. 이 메서드는 Elasticsearch Refresh API를 사용해 마지막 새로 고침 이후 인덱스에서 수행된 모든 작업을 검색할 수 있게 합니다. 이 메서드는 보통 PostgreSQL에 데이터를 로드한 후 호출해 데이터가 인덱싱되고 검색 가능한지 확인합니다.

Elasticsearch 메타데이터를 사용하면 ElasticsearchHelpers의 헬퍼 메서드가 자동으로 포함됩니다. :elastic_helpers 메타데이터로 직접 포함할 수도 있습니다.

SEARCH_SPEC_BENCHMARK 환경 변수를 사용하면 테스트 설정 단계를 벤치마크할 수 있습니다.

SEARCH_SPEC_BENCHMARK=1 bundle exec rspec ee/spec/lib/elastic/latest/merge_request_class_proxy_spec.rb

레거시 Snowplow 이벤트 테스트#

이 섹션에서는 아직 내부 이벤트로 전환되지 않은 이벤트를 테스트하는 방법을 설명합니다.

백엔드#
Warning

Snowplow는 contracts gem을 사용해 런타임 타입 검사를 수행합니다. Snowplow는 테스트와 개발에서 기본적으로 비활성화되어 있으므로 Gitlab::Tracking을 모킹할 때 예외를 포착하기 어려울 수 있습니다.

타입 검사로 인한 런타임 오류를 포착하려면 Gitlab::Tracking#event 호출을 확인하는 expect_snowplow_event를 사용할 수 있습니다.

describe '#show' do
  it 'tracks snowplow events' do
    get :show

    expect_snowplow_event(
      category: 'Experiment',
      action: 'start',
      namespace: group,
      project: project
    )
    expect_snowplow_event(
      category: 'Experiment',
      action: 'sent',
      property: 'property',
      label: 'label',
      namespace: group,
      project: project
    )
  end
end

이벤트가 호출되지 않았음을 확인하려면 expect_no_snowplow_event를 사용할 수 있습니다.

  describe '#show' do
    it 'does not track any snowplow events' do
      get :show

      expect_no_snowplow_event(category: described_class.name, action: 'some_action')
    end
  end

category와 action은 생략할 수 있지만, 불안정한 테스트를 피하려면 최소한 category는 지정해야 합니다. 예를 들어 Users::ActivityService는 API 요청 후에 Snowplow 이벤트를 추적할 수 있으며, 인수를 지정하지 않은 상태에서 이것이 실행되면 expect_no_snowplow_event가 실패합니다.

데이터 속성이 있는 뷰 계층#
Note

아래에 나오는 data-track-* 속성과 have_tracking 매처는 레거시 Snowplow 추적 시스템의 일부입니다. 새 추적에는 data-event-tracking 속성을 대신 사용합니다. 자세한 내용은 마이그레이션 가이드를 참고합니다.

Haml 계층에서 데이터 속성을 사용해 추적을 등록한다면, have_tracking 매처 메서드를 사용해 기대하는 데이터 속성이 할당되었는지 단언할 수 있습니다.

예를 들어 아래 Haml을 테스트해야 한다면

%div{ data: { testid: '_testid_', track_action: 'render', track_label: '_tracking_label_' } }
    it 'assigns the tracking items' do
      render

      expect(rendered).to have_tracking(action: 'render', label: '_tracking_label_', testid: '_testid_')
    end
  it 'assigns the tracking items' do
    render_inline(component)

    expect(page).to have_tracking(action: 'render', label: '_tracking_label_', testid: '_testid_')
  end

추적이 할당되지 않았음을 확인하려면 위 매처와 함께 not_to를 사용할 수 있습니다.

위의 어떤 매처도 실제로 송출된 이벤트를 관찰하지 않습니다. expect_snowplow_event는 Gitlab::Tracking의 목에 대해 단언하고, have_tracking은 렌더링된 마크업에 대해 단언합니다. 백엔드와 프론트엔드 모두에서 GitLab이 송출하는 페이로드를 단언하려면 기능 스펙에서 Snowplow 이벤트 캡처하기를 참고합니다.

스키마에 대한 Snowplow 컨텍스트 테스트#

Snowplow 스키마 매처는 JSON 스키마에 대해 Snowplow 컨텍스트를 테스트해 검증 오류를 줄이는 데 도움이 됩니다. 스키마 매처는 다음 매개변수를 받습니다.

  • schema path
  • context

스키마 매처 스펙을 추가하려면 다음과 같이 합니다.

  1. Iglu 리포지터리에 새 스키마를 추가한 다음, 같은 스키마를 spec/fixtures/product_intelligence/ 디렉터리에 복사합니다.

  2. 복사한 스키마에서 "$schema" 키와 값을 제거합니다. 스펙에는 필요하지 않으며, 키를 유지하면 URL에서 스키마를 찾으려고 시도해 스펙이 실패합니다.

  3. 다음 스니펫을 사용해 스키마 매처를 호출합니다.

    match_snowplow_context_schema(schema_path: '<filename from step 1>', context: <Context Hash> )
    

Prometheus 테스트#

Prometheus 지표는 테스트 실행 간에 유지될 수 있습니다. 각 예제 전에 지표가 초기화되도록 하려면 RSpec 테스트에 :prometheus 태그를 추가합니다.

매처#

RSpec 기대 조건의 의도를 명확하게 하거나 복잡성을 숨기기 위해 커스텀 매처를 만들어야 합니다. 커스텀 매처는 spec/support/matchers/ 아래에 두어야 합니다. 매처가 특정 유형의 스펙(예: 기능 또는 요청)에만 적용된다면 하위 폴더에 둘 수 있지만, 여러 유형의 스펙에 적용된다면 하위 폴더에 두지 않아야 합니다.

be_like_time#

데이터베이스에서 반환된 시간은 Ruby의 시간 객체와 정밀도가 다를 수 있으므로, 스펙에서 비교할 때는 유연한 허용 오차가 필요합니다.

PostgreSQL의 time 및 timestamp 타입은 1마이크로초의 해상도를 가집니다. 그러나 Ruby Time의 정밀도는 OS에 따라 다를 수 있습니다.

다음 스니펫을 살펴봅니다.

project = create(:project)

expect(project.created_at).to eq(Project.find(project.id).created_at)

Linux에서 Time은 최대 9의 정밀도를 가질 수 있으며 project.created_at은 같은 정밀도의 값(예: 2023-04-28 05:53:30.808033064)을 가집니다. 그러나 데이터베이스에 저장되고 로드된 실제 created_at 값(예: 2023-04-28 05:53:30.808033)은 같은 정밀도를 갖지 않으므로 일치 검사가 실패합니다. macOS X에서는 Time의 정밀도가 PostgreSQL timestamp 타입과 일치하므로 일치 검사가 성공할 수 있습니다.

이 문제를 피하려면 be_like_time 또는 be_within을 사용해 시간이 서로 1초 이내인지 비교할 수 있습니다.

예시:

expect(metrics.merged_at).to be_like_time(time)

be_within 예시:

expect(violation.reload.merged_at).to be_within(0.00001.seconds).of(merge_request.merged_at)

have_gitlab_http_status#

have_http_status와 expect(response.status).to보다 have_gitlab_http_status를 사용합니다. 전자는 상태가 일치하지 않을 때마다 응답 본문도 표시할 수 있기 때문입니다. 일부 테스트가 깨지기 시작했을 때 소스를 수정하고 테스트를 다시 실행하지 않고도 이유를 알고 싶을 때 매우 유용합니다.

특히 500 내부 서버 오류가 표시될 때 유용합니다.

206 같은 숫자 표현보다 :no_content 같은 이름이 있는 HTTP 상태를 사용합니다. 지원되는 상태 코드 목록을 참고합니다.

예시:

expect(response).to have_gitlab_http_status(:ok)

이 매처는 기능 스펙에서 Capybara::Session도 받으며 해당 세션의 status_code를 확인합니다.

expect(page).to have_gitlab_http_status(:not_found)

match_schema와 match_response_schema#

match_schema 매처를 사용하면 대상이 JSON 스키마와 일치하는지 검증할 수 있습니다. expect 안의 항목은 JSON 문자열 또는 JSON과 호환되는 데이터 구조일 수 있습니다.

match_response_schema는 요청 스펙의 응답 객체와 함께 사용하는 편의 매처입니다.

예시:

# Matches against spec/fixtures/api/schemas/prometheus/additional_metrics_query_result.json
expect(data).to match_schema('prometheus/additional_metrics_query_result')

# Matches against ee/spec/fixtures/api/schemas/board.json
expect(data).to match_schema('board', dir: 'ee')

# Matches against a schema made up of Ruby data structures
expect(data).to match_schema(Atlassian::Schemata.build_info)

be_valid_json#

be_valid_json을 사용하면 문자열이 JSON으로 파싱되고 비어 있지 않은 결과를 내는지 검증할 수 있습니다. 위의 스키마 매칭과 결합하려면 and를 사용합니다.

expect(json_string).to be_valid_json

expect(json_string).to be_valid_json.and match_schema(schema)

be_one_of(collection)#

include의 반대로, collection에 기대하는 값이 포함되어 있는지 테스트합니다.

expect(:a).to be_one_of(%i[a b c])
expect(:z).not_to be_one_of(%i[a b c])

have_no_testid#

have_testid의 반대입니다.

expect(page).to have_no_testid('relationship-blocks-icon')

쿼리 성능 테스트#

쿼리 성능을 테스트하면 다음을 할 수 있습니다.

  • 코드 블록에 N+1 문제가 없음을 단언합니다.
  • 코드 블록의 쿼리 수가 눈에 띄지 않게 증가하지 않도록 합니다.

기능(:js) 스펙에서는 N+1이나 쿼리 수를 단언하지 않습니다. 브라우저 컨텍스트에서는 쿼리 수 기준선이 불안정합니다. QueryRecorder와 exceed_query_limit 단언은 요청 스펙이나 컨트롤러 스펙에 둡니다. 첫 번째 호출이 캐시를 지연 로드한다면 측정하기 전에 워밍업 요청으로 기록되는 기준선을 미리 채웁니다.

QueryRecorder#

QueryRecorder를 사용하면 주어진 코드 블록에서 수행되는 데이터베이스 쿼리 수를 프로파일링하고 테스트할 수 있습니다.

자세한 내용은 QueryRecorder 섹션을 참고합니다.

GitalyClient#

Gitlab::GitalyClient.get_request_count를 사용하면 주어진 코드 블록에서 수행하는 Gitaly 쿼리 수를 테스트할 수 있습니다.

자세한 내용은 Gitaly Request Counts 섹션을 참고합니다.

공유 컨텍스트와 공유 예제#

하나의 스펙 파일에서만 사용하는 공유 컨텍스트나 공유 예제는 인라인으로 선언할 수 있습니다.

둘 이상의 스펙 파일에서 사용하는 공유 예제는 범위에 따라 위치가 달라집니다.

단일 바운디드 컨텍스트의 공유 예제:

  • 바운디드 컨텍스트의 디렉터리 구조에 둘 수 있습니다(예: ee/spec/requests/api/graphql/remote_development/shared_examples.rb).
  • 이 방식은 바운디드 컨텍스트의 응집도를 유지하며 모듈러 모놀리스 아키텍처와 일치합니다.

여러 바운디드 컨텍스트에서 사용하는 공유 예제:

  • spec/support/shared_* 아래에 두어야 합니다.
  • 특정 유형의 스펙(예: 기능 또는 요청)에만 적용된다면 하위 폴더에 둘 수 있지만, 여러 유형의 스펙에 적용된다면 하위 폴더에 두지 않아야 합니다.

일반 지침:

  • 공유 예제가 특정 바운디드 컨텍스트에서만 사용된다면 그 컨텍스트에 두는 것을 우선합니다.
  • 공유 예제가 서로 다른 바운디드 컨텍스트에서 실제로 공유될 때만 전역 spec/support/shared_* 디렉터리로 옮깁니다.
  • 공유 예제와 공유 컨텍스트 파일은 보통 *_contexts.rb, *_examples.rb, *_shared.rb, *_shared_context_and_examples.rb 같은 이름 패턴을 사용합니다.
  • 목표는 바운디드 컨텍스트의 높은 응집도를 유지하면서 컨텍스트 사이의 결합을 느슨하게 유지하는 것입니다.

느린 공유 예제의 성능 영향#

느린 공유 예제는 그 비용이 배가됩니다. 30초가 걸리는 예제 하나가 스펙 파일 10개에 포함되면 CI 시간이 30초가 아니라 300초가 듭니다.

코드베이스 전반에 폭넓게 포함되는 spec/support/shared_* 아래의 공유 예제는 단일 스펙 예제보다 더 엄격한 성능 기준이 적용됩니다.

지침:

  • 느린 예제를 공유 컨텍스트로 추출하기 전에 로컬 스펙으로 프로파일링하고 최적화합니다.
  • 계약이 데이터베이스 상태를 명시적으로 요구하지 않는 한 공유 예제에서 create 호출을 피합니다. build_stubbed 또는 build를 사용합니다.
  • 공유 예제에 :js가 필요하다면 UI를 단언하는 부분을 분리해 나머지를 일반 요청 스펙으로 실행할 수 있는지 고려합니다.
  • 폭넓게 포함되는 느린 공유 예제를 팩토리 연쇄처럼 다룹니다. 한 곳의 작은 수정이 스위트 전체에서 큰 누적 절감으로 이어집니다. 팩토리 사용 최적화를 참고합니다.

CE/EE 공유 예제 규칙#

스펙 파일에 EE 미러(ee/spec/ 아래의 대응 파일)가 있다면, 핵심 동작을 다루는 공유 예제를 중복해서 작성하지 말고 재사용합니다. 해당 공유 예제를 spec/support/shared_examples/에 정의한 다음 다음과 같이 합니다.

  • CE 스펙은 CE에 존재하는 케이스에 it_behaves_like를 사용합니다.
  • EE 미러는 EE 전용 케이스에 it_behaves_like를 사용하며, 라이선스로 제한되는 케이스는 stub_licensed_features로 감쌉니다.

spec/spec_helper.rb가 spec/support/ 아래의 모든 파일을 require하고, Gitlab.ee?일 때 ee/spec/spec_helper.rb도 require하므로 spec/support/shared_examples/가 적합한 위치입니다. 따라서 CE와 EE 실행 모두 CE 지원 트리를 로드하고, EE 실행은 추가로 ee/spec/support/를 로드합니다.

한 스펙 파일에서 공유 예제를 정의하고 다른 파일에서 사용하지 않습니다. RSpec은 현재 실행에 포함된 스펙 파일만 로드하며 스펙 파일은 다른 스펙 파일을 require하지 않으므로, 정의한 파일이 실행에 포함되지 않으면 공유 예제를 찾을 수 없습니다. bundle exec rspec ee/spec/requests/api/notes_spec.rb만 단독으로 실행하면 Could not find shared examples로 실패합니다. 예제 그룹 안의 shared_examples 호출도 해당 그룹으로 범위가 제한되므로, 두 파일이 모두 로드되어도 다른 파일의 describe에서는 보이지 않습니다.

예를 들어 spec/support/shared_examples/requests/api/notes_shared_examples.rb는 핵심 엔드포인트 동작을 정의합니다.

RSpec.shared_examples 'noteable API' do |parent_type, noteable_type, id_name|
  # core endpoint behavior
end

spec/requests/api/notes_spec.rb는 CE에서 사용할 수 있는 noteable에 이를 사용합니다.

it_behaves_like 'noteable API', 'projects', 'wiki_pages', 'id' do
  let(:parent) { project }
  let(:noteable) { wiki_page_meta }
  let(:note) { wiki_page_meta_note }
end

그리고 ee/spec/requests/api/notes_spec.rb는 EE 전용 noteable에 같은 공유 예제를 사용합니다.

context 'when noteable is a WikiPage::Meta for a group wiki' do
  before do
    stub_licensed_features(group_wikis: true)
  end

  it_behaves_like 'noteable API', 'groups', 'wiki_pages', 'id' do
    let(:parent) { group }
    let(:noteable) { wiki_page_meta }
    let(:note) { wiki_page_meta_note }
  end
end

기존 공유 예제를 spec/support/shared_examples/로 옮길 때는 다른 최상위 RSpec.shared_examples가 이미 같은 이름을 사용하지 않는지 확인합니다. RSpec은 이름이 중복되어도 실패하지 않습니다. 경고를 출력하고 나중에 정의한 것이 조용히 이전 정의를 덮어씁니다. 이는 현재 CE 스펙과 그 EE 미러가 모두 정의하는 이름에 가장 중요합니다. 이러한 정의는 그룹 범위로 제한되므로 작성된 그대로는 충돌하지 않기 때문입니다. 어느 쪽이든 지원 파일로 승격하기 전에 한쪽의 이름을 변경합니다.

헬퍼#

헬퍼는 대개 특정 RSpec 예제의 복잡성을 숨기는 메서드를 제공하는 모듈입니다. 다른 스펙과 공유할 의도가 없다면 RSpec 파일 안에 헬퍼를 정의할 수 있습니다. 그렇지 않으면 spec/support/helpers/ 아래에 두어야 합니다. 헬퍼가 특정 유형의 스펙(예: 기능 또는 요청)에만 적용된다면 하위 폴더에 둘 수 있지만, 여러 유형의 스펙에 적용된다면 하위 폴더에 두지 않아야 합니다.

헬퍼는 spec/support/helpers/를 루트로 하는 Rails 명명 / 네임스페이스 규칙을 따라야 합니다. 예를 들어 spec/support/helpers/features/iteration_helpers.rb는 다음과 같이 정의해야 합니다.

# frozen_string_literal: true

module Features
  module IterationHelpers
    def iteration_period(iteration)
      "#{iteration.start_date.to_fs(:medium)} - #{iteration.due_date.to_fs(:medium)}"
    end
  end
end

헬퍼는 RSpec 구성을 변경해서는 안 됩니다. 예를 들어 위에서 설명한 헬퍼 모듈에는 다음을 포함하지 않아야 합니다.

# bad
RSpec.configure do |config|
  config.include Features::IterationHelpers
end

# good, include in specific spec
RSpec.describe 'Issue Sidebar', feature_category: :team_planning do
  include Features::IterationHelpers
end

Ruby 상수 테스트#

Ruby 상수를 사용하는 코드를 테스트할 때는 상수의 값을 테스트하기보다 상수에 의존하는 동작에 테스트의 초점을 맞춥니다.

예를 들어 다음은 클래스 메서드 .categories의 동작을 테스트하므로 권장됩니다.

  describe '.categories' do
    it 'gets CE unique category names' do
      expect(described_class.categories).to include(
        'deploy_token_packages',
        'user_packages',
        # ...
        'kubernetes_agent'
      )
    end
  end

반면 상수 자체의 값을 테스트하면 코드와 테스트에서 같은 값을 반복할 뿐인 경우가 많아 가치가 거의 없습니다.

  describe CATEGORIES do
  it 'has values' do
    expect(CATEGORIES).to eq([
                            'deploy_token_packages',
                            'user_packages',
                            # ...
                            'kubernetes_agent'
                             ])
  end
end

상수의 오류가 치명적인 영향을 줄 수 있는 중요한 경우에는 상수 값을 테스트하는 것이 추가 안전장치로 유용할 수 있습니다. 예를 들어 GitLab 서비스 전체를 중단시키거나, 고객에게 마땅히 청구해야 할 금액보다 많이 청구하게 하거나, 우주가 내파하게 만들 수 있는 경우입니다.

팩토리#

GitLab은 테스트 픽스처 대체제로 factory_bot을 사용합니다.

  • 팩토리 정의는 spec/factories/에 두며, 해당 모델 이름의 복수형을 사용해 이름을 짓습니다(User 팩토리는 users.rb에 정의).

  • 파일당 최상위 팩토리 정의는 하나만 있어야 합니다.

  • 특히 커스텀 로직을 사용하는 경우 팩토리에 대한 스펙을 만드는 것을 고려합니다. 예를 들어 after(:build) 훅의 로직입니다. 팩토리 스펙은 spec/factories_specs에 저장됩니다.

  • FactoryBot 메서드는 모든 RSpec 그룹에 믹스인됩니다. 즉 FactoryBot.create(...) 대신 create(...)를 호출할 수 있고 호출해야 합니다.

  • 정의와 사용을 깔끔하게 하려면 트레이트를 활용합니다.

  • 팩토리를 정의할 때 결과 레코드가 유효성 검사를 통과하는 데 필요하지 않은 속성은 정의하지 않습니다.

  • 팩토리에서 인스턴스를 만들 때 테스트에 필요하지 않은 속성은 전달하지 않습니다.

  • 콜백에서 연관 관계를 설정할 때는 create / build 대신 암묵적, 명시적 또는 인라인 연관 관계를 사용합니다. 자세한 배경은 이슈 #262624를 참고합니다.

    has_many와 belongs_to 연관 관계가 있는 팩토리를 만들 때는 instance 메서드를 사용해 빌드 중인 객체를 참조합니다. 이렇게 하면 상호 연결된 연관 관계를 사용해 불필요한 레코드 생성을 방지합니다.

    예를 들어 다음 클래스가 있다면

    class Car < ApplicationRecord
      has_many :wheels, inverse_of: :car, foreign_key: :car_id
    end
    
    class Wheel < ApplicationRecord
      belongs_to :car, foreign_key: :car_id, inverse_of: :wheel, optional: false
    end
    

    다음 팩토리를 만들 수 있습니다.

    FactoryBot.define do
      factory :car do
        transient do
          wheels_count { 2 }
        end
    
        wheels do
          Array.new(wheels_count) do
            association(:wheel, car: instance)
          end
        end
      end
    end
    
    FactoryBot.define do
      factory :wheel do
        car { association :car }
      end
    end
    
  • 팩토리는 ActiveRecord 객체로 제한되지 않습니다. 예시를 참고합니다.

  • 팩토리에서 skip_callback 사용을 피합니다. 자세한 내용은 이슈 #247865를 참고합니다.

픽스처#

모든 픽스처는 spec/fixtures/ 아래에 두어야 합니다.

리포지터리#

머지 리퀘스트 병합처럼 일부 기능을 테스트하려면 특정 상태의 Git 리포지터리가 테스트 환경에 있어야 합니다.

리포지터리는 프로젝트 팩토리가 만들 수 있는 것 중 비용이 가장 큰 것 중 하나이므로, 테스트가 실제로 필요로 하는 것과 일치하는 트레이트를 선택합니다.

트레이트 테스트에 다음이 필요한 경우 사용
리포지터리 트레이트 없음 Git 접근이 전혀 필요하지 않음
:small_repo 커밋이 하나이고 유효한 기본 브랜치가 있는 리포지터리
:custom_repo 특정 파일 경로 또는 내용
:repository 여러 브랜치, 태그, 머지 충돌 같은 gitlab-test 히스토리

이 중 무엇이든 선택하기 전에 테스트가 Git을 전혀 사용하는지 확인합니다. 데이터베이스 레코드, 권한, 직렬화만 실행하는 스펙에는 리포지터리가 필요하지 않습니다. 트레이트를 제거하면 가장 큰 단일 절감 효과를 얻을 수 있습니다.

트레이트는 대략 비용 순서로 나열되어 있지만, :custom_repo와 :small_repo는 파일마다 커밋을 하나씩 만들므로 파일이 많은 :custom_repo는 히스토리를 한 번에 로드하는 :repository보다 비용이 더 클 수 있습니다. 표에서의 위치만으로 고르지 말고 테스트가 필요로 하는 것을 기준으로 선택합니다.

테스트 대상 코드가 존재하지만 커밋이 없는 리포지터리와 리포지터리가 없는 프로젝트를 구별해야 할 때는 :empty_repo를 사용합니다. 이 트레이트는 위 트레이트보다 저렴한 대안이 아니라 동작상의 요구 사항을 나타내므로, 테스트가 그 상태에 의존할 때만 선택합니다.

:small_repo#

:small_repo 트레이트는 test.txt 파일 하나가 있는 리포지터리를 만들고 기본 브랜치를 HEAD로 설정합니다. 테스트에 커밋을 확인할 수 있는 리포지터리가 필요하지만 그 안에 무엇이 있는지는 중요하지 않을 때 사용합니다.

let_it_be(:project) { create(:project, :small_repo) }

project.commit을 호출하거나, ref에 대해 파이프라인을 만들거나, 그 밖에 리포지터리가 비어 있지 않아야 하는 스펙에 이 트레이트를 사용합니다.

:custom_repo#

:custom_repo 트레이트는 프로젝트 리포지터리의 기본 브랜치에 어떤 파일이 나타나는지 정확히 지정합니다. 각 파일은 별도의 커밋으로 생성됩니다.

let_it_be(:project) do
  create(
    :project, :custom_repo,
    files: {
      'README.md'       => 'Content here',
      'foo/bar/baz.txt' => 'More content here'
    }
  )
end

그러면 기본 권한과 지정한 내용을 가진 두 개의 파일이 있는 리포지터리가 만들어집니다.

:repository#

GitLab은 현실적인 히스토리가 필요한 경우를 위해 gitlab-test 리포지터리를 유지 관리합니다. :repository 트레이트를 사용하면 그 사본을 가져올 수 있습니다.

let_it_be(:project) { create(:project, :repository) }

이 트레이트는 테스트 환경이 준비한 번들에서 리포지터리를 로드하므로, 프로젝트가 gitlab-test 히스토리 전체를 한 번에 가져옵니다. 브랜치, 태그, 서브모듈, 머지 충돌처럼 그 히스토리에 의존하는 테스트에만 사용합니다. 테스트에 커밋이 존재하기만 하면 된다면 대신 :small_repo를 사용합니다.

구성#

RSpec 구성 파일은 RSpec 구성을 변경하는 파일입니다(예: RSpec.configure do |config| 블록). 이 파일은 spec/support/ 아래에 두어야 합니다.

각 파일은 spec/support/capybara.rb 또는 spec/support/carrierwave.rb처럼 특정 도메인과 관련되어야 합니다.

헬퍼 모듈이 특정 종류의 스펙에만 적용된다면 config.include 호출에 수정자를 추가해야 합니다. 예를 들어 spec/support/helpers/cycle_analytics_helpers.rb가 :lib 및 type: :model 스펙에만 적용된다면 다음과 같이 작성합니다.

RSpec.configure do |config|
  config.include Spec::Support::Helpers::CycleAnalyticsHelpers, :lib
  config.include Spec::Support::Helpers::CycleAnalyticsHelpers, type: :model
end

구성 파일이 config.include로만 이루어져 있다면 이러한 config.include를 spec/spec_helper.rb에 직접 추가할 수 있습니다.

매우 일반적인 헬퍼는 spec/fast_spec_helper.rb 파일이 사용하는 spec/support/rspec.rb 파일에 포함하는 것을 고려합니다. spec/fast_spec_helper.rb 파일에 관한 자세한 내용은 빠른 단위 테스트를 참고합니다.

테스트 환경 로깅#

Gitaly, Workhorse, Elasticsearch, Capybara를 포함해 테스트 환경의 서비스는 테스트를 실행할 때 자동으로 구성되고 시작됩니다. CI에서 실행하거나 서비스를 설치해야 하는 경우 테스트 환경은 설정 시간에 관한 정보를 기록하며, 다음과 같은 로그 메시지를 생성합니다.

==> Setting up Gitaly...
    Gitaly set up in 31.459649 seconds...

==> Setting up GitLab Workhorse...
    GitLab Workhorse set up in 29.695619 seconds...
fatal: update refs/heads/diff-files-symlink-to-image: invalid <newvalue>: 8cfca84
From https://gitlab.com/gitlab-org/gitlab-test
 * [new branch]      diff-files-image-to-symlink -> origin/diff-files-image-to-symlink
 * [new branch]      diff-files-symlink-to-image -> origin/diff-files-symlink-to-image
 * [new branch]      diff-files-symlink-to-text -> origin/diff-files-symlink-to-text
 * [new branch]      diff-files-text-to-symlink -> origin/diff-files-text-to-symlink
   b80faa8..40232f7  snippet/multiple-files -> origin/snippet/multiple-files
 * [new branch]      testing/branch-with-#-hash -> origin/testing/branch-with-#-hash

==> Setting up GitLab Elasticsearch Indexer...
    GitLab Elasticsearch Indexer set up in 26.514623 seconds...

이 정보는 로컬에서 실행할 때와 수행할 작업이 없을 때는 생략됩니다. 이 메시지를 항상 보려면 다음 환경 변수를 설정합니다.

GITLAB_TESTING_LOG_LEVEL=debug

테스트 문서로 돌아가기

테스트 모범 사례

GitLab v19.4
원문 보기

요약

GitLab에서 테스트는 사후에 덧붙이는 것이 아니라 최우선으로 다루는 대상입니다. 기능을 구현할 때는 올바른 역량을 올바른 방식으로 개발하는 데 집중합니다. 테스트 휴리스틱이 이 문제를 해결하는 데 도움이 됩니다. RSpec 테스트를 실행하려면 다음과 같이 합니다.

테스트 설계#

GitLab에서 테스트는 사후에 덧붙이는 것이 아니라 최우선으로 다루는 대상입니다. 기능을 설계할 때처럼 테스트의 설계도 신중하게 고려해야 합니다.

기능을 구현할 때는 올바른 역량을 올바른 방식으로 개발하는 데 집중합니다. 이렇게 하면 범위를 관리 가능한 수준으로 좁힐 수 있습니다. 기능의 테스트를 구현할 때는 올바른 테스트를 개발하는 것은 물론, 테스트가 실패할 수 있는 중요한 경로를 모두 다뤄야 합니다. 그러면 범위가 빠르게 넓어져 관리하기 어려운 수준에 이를 수 있습니다.

테스트 휴리스틱이 이 문제를 해결하는 데 도움이 됩니다. 테스트 휴리스틱은 버그가 코드에 나타나는 여러 일반적인 방식을 간결하게 다룹니다. 테스트를 설계할 때는 알려진 테스트 휴리스틱을 검토하는 시간을 가지고 테스트 설계에 반영합니다. 유용한 휴리스틱은 핸드북의 테스트 가이드에 문서화되어 있습니다.

RSpec#

RSpec 테스트를 실행하려면 다음과 같이 합니다.

# run test for a file
bin/rspec spec/models/project_spec.rb

# run test for the example on line 10 on that file
bin/rspec spec/models/project_spec.rb:10

# run tests matching the example name has that string
bin/rspec spec/models/project_spec.rb -e associations

# run all tests, will take hours for GitLab codebase!
bin/rspec

Guard를 사용하면 변경 사항을 지속적으로 감시하면서 일치하는 테스트만 실행할 수 있습니다.

bundle exec guard

spring과 guard를 함께 사용하는 경우에는 spring을 활용하도록 SPRING=1 bundle exec guard를 대신 사용합니다.

일반 지침#

  • 최상위 RSpec.describe ClassName 블록은 하나만 사용합니다.
  • 클래스 메서드는 .method로, 인스턴스 메서드는 #method로 설명합니다.
  • 분기 로직을 테스트할 때는 context를 사용합니다(RSpec/AvoidConditionalStatements RuboCop Cop - MR).
  • 테스트의 순서를 클래스 안의 순서와 맞추려고 노력합니다.
  • 가능하면 표 기반 테스트를 우선 사용합니다.
  • Four-Phase Test 패턴을 따르되, 줄바꿈으로 단계를 구분합니다.
  • 'localhost'를 하드코딩하지 말고 Gitlab.config.gitlab.host를 사용합니다.
  • 테스트에서 URL을 문자열로 직접 쓸 때는 example.com, gitlab.example.com을 사용합니다. 이렇게 하면 실제 URL을 사용하지 않게 됩니다.
  • 시퀀스로 생성되는 속성의 절대값을 대상으로 단언하지 않습니다 (참고: 주의 사항).
  • expect_any_instance_of 또는 allow_any_instance_of 사용을 피합니다 (참고: 주의 사항).
  • 훅에 :each 인수를 지정하지 않습니다. 기본값이기 때문입니다.
  • before와 after 훅에서는 :all보다 :context 범위를 사용합니다.
  • 특정 요소에 작동하는 evaluate_script("$('.js-foo').testSomething()")(또는 execute_script)를 사용할 때는, 그 전에 find('.js-foo') 같은 Capybara 매처를 사용해 요소가 실제로 존재하는지 확인합니다.
  • 실행하려는 스펙 일부만 분리하려면 focus: true를 사용합니다.
  • 테스트 하나에 기대 조건이 둘 이상 있으면 :aggregate_failures를 사용합니다.
  • 테스트 자체로 설명이 충분하다면 빈 테스트 설명 블록에는 it do 대신 specify를 사용합니다.
  • 존재하지 않는 값이 필요하면 non_existing_record_id, non_existing_record_iid, non_existing_record_access_level을 사용합니다. 어떤 프로젝트도 사용하지 않는 유효한 해시 스토리지 경로가 필요하면 non_existing_project_hashed_path를 사용합니다.
  • 존재하면 안 되는 레코드에 123, 1234, 999 같은 임의의 값을 사용하지 않습니다. 이 값들은 CI 데이터베이스에 존재할 수 있습니다.
  • Model.maximum(:id) + 1 또는 Model.last.id + 1 같은 쿼리로 사용하지 않는 ID를 계산하지 않습니다. 이러한 쿼리는 동시에 일어나는 데이터베이스 쓰기와 경합할 수 있고 불필요한 쿼리를 추가합니다. 대신 존재하지 않는 레코드 헬퍼를 사용합니다.
  • 존재하지 않는 레코드 ID 헬퍼는 32비트 정수의 최댓값인 ACTIVE_MODEL_INTEGER_MAX를 반환하며, CI 실행 중에는 어떤 시퀀스도 이 값에 도달하지 않습니다.
  • 새 테스트를 작성할 때는 통과하는지 단언하기 전에 예상한 방식으로 실패하는지 먼저 확인합니다. 조건을 반전하거나 테스트 대상 동작을 제거한 상태로 스펙을 실행해 실패 메시지가 의미 있는지 확인합니다. 실패할 수 없는 테스트는 커버리지를 제공하지 못합니다.

애플리케이션 코드 즉시 로딩#

테스트 환경에서는 테스트 실행 시간을 단축하기 위해 기본적으로 애플리케이션 코드를 즉시 로딩하지 않습니다. 테스트를 실행할 때 즉시 로딩을 활성화해야 한다면 GITLAB_TEST_EAGER_LOAD 환경 변수를 사용합니다.

GITLAB_TEST_EAGER_LOAD=1 bin/rspec spec/models/project_spec.rb

테스트가 모든 애플리케이션 코드가 로드되어 있어야 동작한다면 :eager_load 태그를 추가합니다. 이렇게 하면 테스트를 실행하기 전에 애플리케이션 코드가 즉시 로딩됩니다.

Ruby 경고#

스펙을 실행할 때는 기본적으로 사용 중단 경고가 활성화되어 있습니다. 이 경고가 개발자에게 더 잘 보이도록 하면 새로운 Ruby 버전으로 업그레이드하는 데 도움이 됩니다.

환경 변수 SILENCE_DEPRECATIONS를 설정하면 사용 중단 경고를 숨길 수 있습니다. 예를 들면 다음과 같습니다.

# silence all deprecation warnings
SILENCE_DEPRECATIONS=1 bin/rspec spec/models/project_spec.rb

테스트 순서#

모든 신규 스펙 파일은 테스트 순서에 의존하는 불안정한(flaky) 테스트를 드러내기 위해 무작위 순서로 실행됩니다.

무작위로 실행되면 다음과 같이 표시됩니다.

  • 예제 그룹 설명 아래에 # order random 문자열이 추가됩니다.
  • 사용된 시드가 스펙 출력의 테스트 스위트 요약 아래에 표시됩니다. 예를 들면 Randomized with seed 27443입니다.

아직 정해진 순서로 실행되는 스펙 파일의 목록은 rspec_order_todo.yml을 참고합니다.

스펙 파일을 무작위 순서로 실행되게 하려면 다음 명령으로 순서 의존성을 확인합니다.

scripts/rspec_check_order_dependence spec/models/project_spec.rb

스펙이 검사를 통과하면 스크립트가 해당 스펙을 rspec_order_todo.yml에서 자동으로 제거합니다.

스펙이 검사를 통과하지 못하면 무작위 순서로 실행하기 전에 먼저 수정해야 합니다.

테스트 불안정성#

불안정한 테스트를 방지하기 위한 프로세스에 관한 자세한 내용은 Unhealthy tests 페이지를 참고합니다.

테스트 속도 저하#

GitLab에는 방대한 테스트 스위트가 있으며, 병렬화 없이는 실행하는 데 몇 시간이 걸릴 수 있습니다. 정확하고 효과적이면서 동시에 빠른 테스트를 작성하려는 노력이 중요합니다.

테스트 성능은 품질과 속도를 유지하는 데 중요하며, CI 빌드 시간과 그에 따른 고정 비용에 직접적인 영향을 줍니다. 우리는 철저하고 정확하며 빠른 테스트를 원합니다. 여기에서는 이를 달성하는 데 사용할 수 있는 도구와 기법에 관한 정보를 확인할 수 있습니다.

느린 테스트를 방지하기 위한 프로세스에 관한 자세한 내용은 Unhealthy tests 페이지를 참고합니다.

필요하지 않은 기능을 요청하지 않기#

예제 또는 상위 컨텍스트에 어노테이션을 붙이면 예제에 기능을 쉽게 추가할 수 있습니다. 예를 들면 다음과 같습니다.

  • 기능 스펙의 :js는 JavaScript를 완전히 지원하는 헤드리스 브라우저를 실행합니다.
  • :clean_gitlab_redis_cache는 예제에 깨끗한 Redis 캐시를 제공합니다.
  • :request_store는 예제에 요청 저장소를 제공합니다.

테스트 의존성을 줄여야 하며, 기능을 사용하지 않으면 필요한 설정의 양도 줄어듭니다.

특히 :js는 피해야 합니다. 기능 테스트에서 브라우저의 JavaScript 반응성이 필요한 경우(예: Vue.js 컴포넌트 클릭)에만 사용해야 합니다. 헤드리스 브라우저를 사용하는 것은 애플리케이션의 HTML 응답을 파싱하는 것보다 훨씬 느립니다.

팩토리 트레이트에도 같은 원칙이 적용됩니다. :repository 같은 트레이트는 하나의 기능입니다. 이 트레이트는 팩토리가 Git 리포지터리를 생성하게 하며, 이는 주변의 데이터베이스 쓰기보다 훨씬 비용이 큽니다. 테스트가 실제로 사용하는 트레이트만 전달합니다. 리포지터리 트레이트에 관해서는 리포지터리를 참고합니다.

프로파일링: 테스트가 시간을 소비하는 위치 확인#

rspec-stackprof를 사용하면 테스트가 시간을 어디에 쓰는지 보여 주는 플레임 그래프를 생성할 수 있습니다.

이 gem은 JSON 보고서를 생성하며, 이를 https://www.speedscope.app에 업로드하면 대화형으로 시각화할 수 있습니다.

설치#

stackprof gem은 GitLab에 이미 설치되어 있으며, JSON 보고서를 생성하는 스크립트(bin/rspec-stackprof)도 있습니다.

# Optional: install the `speedscope` package to easily upload the JSON report to https://www.speedscope.app
npm install -g speedscope
JSON 보고서 생성#
bin/rspec-stackprof --speedscope=true <your_slow_spec>
# There will be the name of the report displayed when the script ends.

# Upload the JSON report to speedscope.app
speedscope tmp/<your-json-report>.json
플레임 그래프 해석 방법#

플레임 그래프를 해석하고 탐색하는 데 유용한 팁은 다음과 같습니다.

  • 플레임 그래프에는 여러 보기가 있습니다. 함수 호출이 많은 경우(예: 기능 스펙)에는 Left Heavy가 특히 유용합니다.
  • 확대와 축소가 가능합니다. 탐색 문서를 참고합니다.
  • 느린 기능 테스트를 작업하는 경우 검색창에 Capybara::DSL#을 검색하면 수행된 Capybara 동작과 각 동작에 걸리는 시간을 확인할 수 있습니다.

분석 예시는 #414929 또는 #375004를 참고합니다.

팩토리 사용 최적화#

테스트가 느려지는 흔한 원인은 객체를 과도하게 생성하여 연산과 데이터베이스 시간이 늘어나는 것입니다. 팩토리는 개발에 필수적이지만 데이터베이스에 데이터를 너무 쉽게 넣을 수 있게 해서 최적화할 여지가 생길 수 있습니다.

염두에 둘 두 가지 기본 기법은 다음과 같습니다.

  • 줄이기: 객체를 생성하지 않고, 영속화하지 않습니다.
  • 재사용: 공유 객체, 특히 직접 검사하지 않는 중첩 객체는 대체로 공유할 수 있습니다.

생성을 피하려면 다음을 염두에 둘 필요가 있습니다.

  • instance_double과 spy는 FactoryBot.build(...)보다 빠릅니다.
  • FactoryBot.build(...)와 .build_stubbed는 .create보다 빠릅니다.
  • build_stubbed는 대개 build보다 빠릅니다. 데이터베이스에 전혀 접근하지 않고, 가짜 id와 타임스탬프를 할당하며, 연관 레코드를 빌드하지 않고 스텁으로 대체하므로 build가 여전히 일으킬 수 있는 연관 관계 연쇄를 피합니다. 테스트 대상 코드가 객체를 영속화하거나 실제 연관 레코드에 의존하는 경우가 아니라면 build_stubbed를 사용합니다.
  • build, build_stubbed, attributes_for, spy, instance_double을 사용할 수 있다면 객체를 create하지 않습니다. 데이터베이스 영속화는 느립니다.

Factory Doctor를 사용하면 주어진 테스트에서 데이터베이스 영속화가 필요하지 않은 경우를 찾을 수 있습니다.

팩토리 최적화 예시: 1, 2.

# run test for path
FDOC=1 bin/rspec spec/[path]/[to]/[spec].rb

흔한 변경은 create 대신 build 또는 build_stubbed를 사용하는 것입니다.

# Old
let(:project) { create(:project) }

# New
let(:project) { build(:project) }

Factory Profiler는 팩토리를 통한 반복적인 데이터베이스 영속화를 식별하는 데 도움이 됩니다.

# run test for path
FPROF=1 bin/rspec spec/[path]/[to]/[spec].rb

# to visualize with a flamegraph
FPROF=flamegraph bin/rspec spec/[path]/[to]/[spec].rb

팩토리가 대량으로 생성되는 흔한 원인은 팩토리가 연관 관계를 생성하고 다시 생성할 때 발생하는 팩토리 연쇄입니다. total time과 top-level time 수치가 눈에 띄게 차이 나는 것으로 식별할 수 있습니다.

   total   top-level     total time      time per call      top-level time               name

     208           0        9.5812s            0.0461s             0.0000s          namespace
     208          76       37.4214s            0.1799s            13.8749s            project

위 표에서 namespace 객체를 명시적으로 생성한 적이 없지만 (top-level == 0) 모두 암묵적으로 생성되었음을 알 수 있습니다. 그런데도 결국 208개가 생성되었고(프로젝트당 하나) 9.5초가 소요됩니다.

암묵적 상위 연관 관계에서 이름이 지정된 팩토리를 호출할 때마다 하나의 객체를 재사용하려면 FactoryDefault를 사용할 수 있습니다.

RSpec.describe API::Search, factory_default: :keep do
  let_it_be(:namespace) { create_default(:namespace) }

그러면 생성하는 모든 프로젝트가 namespace: namespace로 전달하지 않아도 이 namespace를 사용합니다. let_it_be와 함께 동작하게 하려면 factory_default: :keep을 명시적으로 지정해야 합니다. 이렇게 하면 각 예제마다 기본 팩토리를 다시 생성하는 대신, 스위트의 모든 예제에서 기본 팩토리를 유지합니다.

테스트 예제 사이에 의도하지 않은 의존이 생기는 것을 방지하기 위해 create_default로 생성한 객체는 동결됩니다.

프로젝트를 208개나 만들 필요는 없을 수 있습니다. 하나를 만들어 재사용할 수 있습니다. 또한 생성하는 프로젝트 중 우리가 요청한 것은 약 3분의 1 (76/208)에 불과합니다. 프로젝트에도 기본값을 설정하면 이점이 있습니다.

  let_it_be(:project) { create_default(:project) }

이 경우 total time과 top-level time 수치가 더 가깝게 일치합니다.

   total   top-level     total time      time per call      top-level time               name

      31          30        4.6378s            0.1496s             4.5366s            project
       8           8        0.0477s            0.0477s             0.0477s          namespace
let 알아보기#

테스트에서 객체를 생성하고 변수에 저장하는 방법은 여러 가지입니다. 효율이 낮은 것부터 높은 것 순서로 나열하면 다음과 같습니다.

  • let!은 각 예제가 실행되기 전에 객체를 생성합니다. 또한 예제마다 새 객체를 생성합니다. 객체를 명시적으로 참조하지 않으면서 각 예제 전에 깨끗한 객체를 생성해야 할 때만 이 옵션을 사용해야 합니다.
  • let은 객체를 지연 생성합니다. 객체가 호출될 때까지 생성되지 않습니다. let은 예제마다 새 객체를 생성하므로 일반적으로 비효율적입니다. let은 단순한 값에는 괜찮습니다. 그러나 팩토리 같은 데이터베이스 모델을 다룰 때는 더 효율적인 let 변형이 가장 좋습니다.
  • let_it_be_with_refind는 let_it_be_with_reload와 비슷하게 동작하지만, 전자는 ActiveRecord::Base#find를 호출하고 후자는 ActiveRecord::Base#reload를 호출합니다. 일반적으로 reload가 refind보다 빠릅니다.
  • let_it_be_with_reload는 같은 컨텍스트의 모든 예제에 대해 객체를 한 번 생성하지만, 각 예제가 끝나면 데이터베이스 변경 사항이 롤백되고 object.reload가 호출되어 객체가 원래 상태로 복원됩니다. 즉, 예제 실행 전이나 도중에 객체를 변경할 수 있습니다. 그러나 상태가 다른 모델로 누출되는 경우가 발생할 수 있습니다. 이런 경우에는 특히 예제가 몇 개 없다면 let이 더 쉬운 선택일 수 있습니다.
  • let_it_be는 같은 컨텍스트의 모든 예제에 대해 객체를 한 번 생성합니다. 예제마다 바뀔 필요가 없는 객체에는 let과 let!을 대체할 훌륭한 방법입니다. let_it_be를 사용하면 데이터베이스 모델을 생성하는 테스트의 속도를 크게 높일 수 있습니다. 자세한 내용과 예시는 https://github.com/test-prof/test-prof/blob/master/docs/recipes/let_it_be.md#let-it-be를 참고합니다.

let_it_be 안의 객체는 변경할 수 없습니다. 이 프로젝트에서 let_it_be는 기본적으로 freeze: true이며, 이는 spec/support/let_it_be.rb에 설정되어 있습니다. 객체를 변경하면 그 시점에 FrozenError가 발생하며, 이는 let_it_be 선언 안에서 변경된 객체에 적용되는 주의 사항을 드러냅니다(1, 2).

예제가 객체를 수정해야 한다면 let_it_be_with_reload 또는 let_it_be_with_refind를 사용합니다. 둘 다 freeze: false를 설정하고 앞서 설명한 서로 다른 메커니즘으로 예제 사이에 객체를 복원합니다.

# Raises FrozenError when an example modifies the project
let_it_be(:project) { create(:project) }

# The object can be modified, and is restored between examples
let_it_be_with_reload(:project) { create(:project) }

freeze: false만 사용하면 객체의 동결은 해제되지만 예제 사이에 복원되지는 않습니다. 이후 예제가 관찰할 수 있는 방식으로 객체가 변경되지 않는 경우에만 사용합니다. 예를 들어 변경이 before_all 훅 안에서 일어나는 경우입니다. 확실하지 않다면 let_it_be_with_reload를 사용합니다.

let_it_be의 동결에 관한 자세한 내용은 https://github.com/test-prof/test-prof/blob/master/docs/recipes/let_it_be.md#state-leakage-detection를 참고합니다.

let_it_be는 객체를 한 번만 인스턴스화하고 그 인스턴스를 예제 간에 공유하므로 가장 최적화된 옵션입니다. let_it_be 대신 let이 필요하다고 느껴진다면 let_it_be_with_reload를 시도합니다.

# Old
let(:project) { create(:project) }

# New
let_it_be(:project) { create(:project) }

# If you need to expect changes to the object in the test
let_it_be_with_reload(:project) { create(:project) }

다음은 let_it_be를 사용할 수 없지만 let_it_be_with_reload가 let보다 효율적인 경우의 예시입니다.

let_it_be(:user) { create(:user) }
let_it_be_with_reload(:project) { create(:project) } # The test will fail if `let_it_be` is used

context 'with a developer' do
  before_all do
    project.add_developer(user)
  end

  it 'project has an owner and a developer' do
    expect(project.members.map(&:access_level)).to match_array([Gitlab::Access::OWNER, Gitlab::Access::DEVELOPER])
  end
end

context 'with a maintainer' do
  before_all do
    project.add_maintainer(user)
  end

  it 'project has an owner and a maintainer' do
    expect(project.members.map(&:access_level)).to match_array([Gitlab::Access::OWNER, Gitlab::Access::MAINTAINER])
  end
end

각 before_all은 예제마다가 아니라 컨텍스트마다 한 번 실행되며, 멤버십은 컨텍스트가 끝날 때 롤백됩니다. RSpec/BeforeAllRoleAssignment RuboCop 규칙이 이를 강제합니다. let_it_be 객체에 권한을 할당하는 before 훅은 위반입니다.

팩토리를 통한 멤버 할당#

컨텍스트의 모든 예제에 같은 멤버십이 필요하다면 훅 대신 팩토리에 전달합니다. 프로젝트와 그룹 팩토리는 guests, reporters, developers, maintainers, owners처럼 권한마다 하나씩 일시적 속성을 받습니다. 각 속성은 사용자 한 명 또는 배열을 받습니다.

let_it_be(:reviewer) { create(:user) }
let_it_be(:approver) { create(:user) }
let_it_be(:project) { create(:project, developers: reviewer) }
let_it_be(:group) { create(:group, maintainers: [reviewer, approver]) }

사용자 팩토리도 반대 방향에서 같은 관계를 받으며, guest_of, reporter_of, developer_of, maintainer_of, owner_of 같은 속성을 사용합니다.

let_it_be(:project) { create(:project) }
let_it_be(:maintainer) { create(:user, maintainer_of: project) }

이 속성들은 add_developer 및 다른 권한 메서드와 같은 코드 경로로 멤버십을 생성합니다. 사용자가 한 명이면 동등한 before_all과 비교해 쿼리 수가 줄어들지는 않습니다. 위의 maintainers: [reviewer, approver]처럼 사용자 배열인 경우에는 줄어듭니다. 사용자, 이메일, 기존 멤버십 조회가 before_all을 호출할 때마다가 아니라 배치 전체에 대해 한 번만 실행되지만, 각 멤버십은 여전히 개별적으로 인가되고 삽입됩니다. 어느 경우든 팩토리 속성을 사용하는 것이 좋습니다. 설정이 한곳에 모이고, 객체가 동결되기 전에 멤버십이 존재하므로 스펙에 별도의 훅이나 let_it_be_with_reload가 필요하지 않기 때문입니다.

let을 let_it_be로 변환하지 않아야 하는 경우#

Danger는 프로젝트 팩토리를 발견할 때마다 let(:project) { create(:project) }를 let_it_be로 변환하라고 제안합니다. 이 제안은 휴리스틱이며, 다음과 같은 경우에는 거절해도 됩니다.

  • 예제가 테스트 대상 객체를 수정합니다. let_it_be_with_reload를 사용하거나, 예제가 몇 개 없다면 let을 유지합니다.
  • 팩토리가 allow로 메서드를 스텁합니다. 팩토리 안에서 메서드 스텁하기를 참고합니다.
  • 스펙이 마이그레이션 스펙 또는 Rake 태스크 스펙이거나 :delete 태그가 붙어 있습니다. 공통 테스트 설정을 참고합니다.

예제가 하나뿐인 컨텍스트라는 이유만으로는 근거가 약합니다. 예제가 객체를 사용한다면 let과 let_it_be 모두 객체를 한 번 생성하므로 어느 쪽도 더 빠르지 않습니다. 예외는 일부 예제가 전혀 참조하지 않는 객체입니다. let은 지연 생성이라 이를 건너뛰지만, let_it_be는 즉시 생성이라 항상 생성합니다. 이 이유로 거절하기 전에 객체가 실제로 사용되는지 확인합니다.

제안을 거절할 때는 이 중 어느 경우에 해당하는지 밝혀 두면, 다음에 읽는 사람이 같은 내용을 다시 파악할 필요가 없습니다.

팩토리 안에서 메서드 스텁하기#

팩토리에서는 allow(object).to receive(:method) 사용을 피해야 합니다. 공통 테스트 설정에서 설명한 대로 팩토리를 let_it_be와 함께 사용할 수 없게 되기 때문입니다.

대신 stub_method를 사용해 메서드를 스텁할 수 있습니다.

  before(:create) do |user, evaluator|
    # Stub a method.
    stub_method(user, :some_method) { 'stubbed!' }
    # Or with arguments, including named ones
    stub_method(user, :some_method) { |var1| "Returning #{var1}!" }
    stub_method(user, :some_method) { |var1: 'default'| "Returning #{var1}!" }
  end

  # Un-stub the method.
  # This may be useful where the stubbed object is created with `let_it_be`
  # and you want to reset the method between tests.
  after(:create) do  |user, evaluator|
    restore_original_method(user, :some_method)
    # or
    restore_original_methods(user)
  end
Note

stub_method는 let_it_be_with_refind와 함께 사용하면 동작하지 않습니다. stub_method는 인스턴스의 메서드를 스텁하는데, let_it_be_with_refind는 실행할 때마다 객체의 새 인스턴스를 생성하기 때문입니다.

stub_method는 메서드 존재 여부 확인과 메서드 인수 개수(arity) 확인을 지원하지 않습니다.

Warning

stub_method는 팩토리에서만 사용해야 합니다. 다른 곳에서 사용하는 것은 강력히 권장하지 않습니다. 가능하다면 RSpec mocks를 사용하는 것을 고려합니다.

멤버 액세스 수준 스텁하기#

Project나 Group 같은 팩토리 스텁의 멤버 액세스 수준을 스텁하려면 stub_member_access_level을 사용합니다.

let(:project) { build_stubbed(:project) }
let(:maintainer) { build_stubbed(:user) }
let(:policy) { ProjectPolicy.new(maintainer, project) }

it 'allows admin_project ability' do
  stub_member_access_level(project, maintainer: maintainer)

  expect(policy).to be_allowed(:admin_project)
end
Note

테스트 코드가 project_authorizations 또는 Member 레코드의 영속화에 의존한다면 이 스텁 헬퍼를 사용하지 않습니다. 대신 Project#add_member 또는 Group#add_member를 사용합니다.

추가 프로파일링 지표#

rspec_profiling gem을 사용하면 예를 들어 테스트를 실행할 때 수행되는 SQL 쿼리 수를 진단할 수 있습니다.

이는 테스트 대상이 아닌 부분을 모킹할 수 있는 테스트가 유발한 일부 애플리케이션 측 SQL 쿼리 때문일 수 있습니다(예: !123810).

성능 문서의 안내를 참고합니다.

느린 기능 테스트 문제 해결#

느린 기능 테스트는 일반적으로 다른 테스트와 같은 방식으로 최적화할 수 있습니다. 다만 문제 해결 과정을 더 효과적으로 만들어 주는 몇 가지 구체적인 기법이 있습니다.

기능 테스트가 UI에서 수행하는 동작 확인#
# Before
bin/rspec ./spec/features/admin/admin_settings_spec.rb:992

# After
WEBDRIVER_HEADLESS=0 bin/rspec ./spec/features/admin/admin_settings_spec.rb:992

자세한 내용은 표시되는 브라우저에서 :js 스펙 실행을 참고합니다.

프로파일링할 때 Capybara::DSL# 검색#

stackprof 플레임 그래프를 사용할 때 검색창에 Capybara::DSL#을 검색하면 수행된 Capybara 동작과 각 동작에 걸리는 시간을 확인할 수 있습니다.

느린 테스트 식별#

프로파일링과 함께 스펙을 실행하는 것은 스펙 최적화를 시작하는 좋은 방법입니다. 다음 명령으로 실행할 수 있습니다.

bundle exec rspec --profile -- path/to/spec_file.rb

그러면 다음과 같은 정보가 포함됩니다.

Top 10 slowest examples (10.69 seconds, 7.7% of total time):
  Issue behaves like an editable mentionable creates new cross-reference notes when the mentionable text is edited
    1.62 seconds ./spec/support/shared_examples/models/mentionable_shared_examples.rb:164
  Issue relative positioning behaves like a class that supports relative positioning .move_nulls_to_end manages to move nulls to the end, stacking if we cannot create enough space
    1.39 seconds ./spec/support/shared_examples/models/relative_positioning_shared_examples.rb:88
  Issue relative positioning behaves like a class that supports relative positioning .move_nulls_to_start manages to move nulls to the end, stacking if we cannot create enough space
    1.27 seconds ./spec/support/shared_examples/models/relative_positioning_shared_examples.rb:180
  Issue behaves like an editable mentionable behaves like a mentionable extracts references from its reference property
    0.99253 seconds ./spec/support/shared_examples/models/mentionable_shared_examples.rb:69
  Issue behaves like an editable mentionable behaves like a mentionable creates cross-reference notes
    0.94987 seconds ./spec/support/shared_examples/models/mentionable_shared_examples.rb:101
  Issue behaves like an editable mentionable behaves like a mentionable when there are cached markdown fields sends in cached markdown fields when appropriate
    0.94148 seconds ./spec/support/shared_examples/models/mentionable_shared_examples.rb:86
  Issue behaves like an editable mentionable when there are cached markdown fields when the markdown cache is stale persists the refreshed cache so that it does not have to be refreshed every time
    0.92833 seconds ./spec/support/shared_examples/models/mentionable_shared_examples.rb:153
  Issue behaves like an editable mentionable when there are cached markdown fields refreshes markdown cache if necessary
    0.88153 seconds ./spec/support/shared_examples/models/mentionable_shared_examples.rb:130
  Issue behaves like an editable mentionable behaves like a mentionable generates a descriptive back-reference
    0.86914 seconds ./spec/support/shared_examples/models/mentionable_shared_examples.rb:65
  Issue#related_issues returns only authorized related issues for given user
    0.84242 seconds ./spec/models/issue_spec.rb:335

Finished in 2 minutes 19 seconds (files took 1 minute 4.42 seconds to load)
277 examples, 0 failures, 1 pending

이 결과에서 스펙에서 비용이 가장 큰 예제를 확인할 수 있으며, 이것이 출발점이 됩니다. 여기서 비용이 가장 큰 예제는 공유 예제에 있습니다. 공유 예제는 여러 곳에서 호출되므로 이를 줄이면 대체로 더 큰 효과가 있습니다.

비용이 큰 동작의 반복 피하기#

개별 예제는 매우 명확하고 스펙이 명세 역할을 하는 데 도움이 되지만, 다음 예시는 비용이 큰 동작을 결합하는 방법을 보여 줍니다.

subject { described_class.new(arg_0, arg_1) }

it 'creates an event' do
  expect { subject.execute }.to change(Event, :count).by(1)
end

it 'sets the frobulance' do
  expect { subject.execute }.to change { arg_0.reset.frobulance }.to('wibble')
end

it 'schedules a background job' do
  expect(BackgroundJob).to receive(:perform_async)

  subject.execute
end

subject.execute 호출의 비용이 크다면, 서로 다른 단언을 하려고 같은 동작을 반복하는 셈입니다. 예제를 결합하면 이러한 반복을 줄일 수 있습니다.

it 'performs the expected side-effects' do
  expect(BackgroundJob).to receive(:perform_async)

  expect { subject.execute }
    .to change(Event, :count).by(1)
    .and change { arg_0.frobulance }.to('wibble')
end

이 방법은 성능 향상을 위해 명확성과 테스트 독립성을 희생하므로 신중하게 사용합니다.

테스트를 결합할 때는 첫 번째 실패만이 아니라 전체 결과를 볼 수 있도록 :aggregate_failures 사용을 고려합니다.

wait_for_requests 또는 wait_for_all_requests를 절대 사용하지 않기#

기능 스펙에서 wait_for_requests 또는 wait_for_all_requests를 사용하지 않습니다. RSpec/AvoidWaitForRequests cop이 두 헬퍼를 모두 금지합니다.

이 헬퍼들은 추적 중인 진행 중인 요청이 없어질 때까지만 기다릴 뿐, 스펙이 필요로 하는 결과를 기다리지 않습니다. 요청 하나가 다른 요청을 유발할 수 있습니다. 두 요청 사이에 폴링이 실행되면, 헬퍼는 페이지가 기대한 상태에 도달하기 전에 반환됩니다. 또한 Vue 재렌더링, 리디렉션, 비동기 후속 쓰기, 브라우저가 시작한 다운로드도 기다리지 않습니다.

.rubocop_todo/rspec/avoid_wait_for_requests.yml에 예외를 추가하거나 cop을 인라인으로 비활성화하지 않습니다. 헬퍼를 페이지별 대기 매처로 교체하거나, 눈에 보이는 결과가 없다면 좁게 지정한 wait_for 조건으로 교체합니다. 대안에 관한 안내는 존재하지 않을 것으로 기대하는 요소를 기다리지 않기, 일시적인 컨트롤이 아니라 안정적인 최종 상태를 단언하기, wait_for로 브라우저 부수 효과 폴링하기를 참고합니다.

존재하지 않을 것으로 기대하는 요소를 기다리지 않기#

Capybara의 쿼리 메서드는 조건이 충족되면 즉시 반환하거나 기본 제한 시간 전체를 기다립니다. 긍정 형태는 요소가 나타날 때까지 기다리고, 부정 형태는 요소가 사라질 때까지 기다립니다. 기대하는 상황에 맞는 형태를 항상 사용해 빠른 경로가 일반적인 경로가 되게 합니다. 예를 들어 숨겨진 링크는 다음과 같이 확인합니다.

# Good: returns immediately when absent
expect(page).to have_no_link('Edit')

# Bad: waits the full timeout
expect(page.has_link?('Edit')).to be(false)

중요: 요소가 렌더링을 마치기 전에 부재 확인이 통과할 수 있습니다. 부재를 확인하기 전에 항상 페이지가 로드되었는지 확인합니다. 예를 들어 긍정 매처 또는 within_* 블록을 사용합니다.

expect(page).to have_testid('search-filter')   # confirm page is loaded
expect(page).to have_no_link('Edit')           # then check absence

다음 상호작용이나 단언 전에 긍정 매처로 페이지가 기대한 상태에 도달했는지 확인합니다. 부재 확인 전에만이 아니라 데이터베이스나 모델 상태를 읽기 전, 그리고 이동하기 전에도 확인합니다. 눈에 보이는 기대 결과(have_content, have_current_path, have_css)를 단언하는 것을 우선합니다.

조건부 로직에서 대기를 건너뛰려면 wait: 0을 사용합니다. 참고: 조건부 로직을 피할 수 없을 때만, 그리고 로드되었음을 이미 확인한 영역 안에서만 사용합니다. 그렇지 않으면 잘못된 답을 얻습니다. 테스트의 조건부 로직은 스펙을 비결정적으로 만듭니다.

within_testid('search-filter') do
  click_link 'Edit' if has_link?('Edit', wait: 0)
end

일시적인 컨트롤이 아니라 안정적인 최종 상태를 단언하기#

비동기 변경을 유발하는 동작 후에는, 방금 상호작용한 컨트롤이 컴포넌트가 다시 렌더링되기 전에 일시적인 loading/비활성화 상태를 거치며 레이블이 바뀌는 경우가 많습니다. 동작이 완료되었음을 확인하려고 그 컨트롤을 단언하면 경합이 발생합니다. 컨트롤이 안정되기 전까지 여러 번 바뀌기 때문입니다.

동기화를 위해 버튼의 레이블이나 disabled 상태를 단언하지 않습니다. 변경이 완전히 처리된 후에만 나타나는 안정적인 최종 상태를 단언합니다. 예를 들면 상태 텍스트나 알림 메시지입니다.

# Bad: races the in-flight mutation. The button passes through a transient
# loading/disabled state and keeps its old label, so these assertions can
# pass before the runnerUpdate mutation re-renders the row.
click_button 'Pause'
expect(page).to have_button 'Resume', disabled: false
expect(page).not_to have_button 'Pause'

click_button 'Resume'
expect(page).to have_button 'Pause', disabled: false
expect(page).not_to have_button 'Resume'

# Good: assert on the stable end-state text that only appears once the
# mutation has resolved. This still exercises the pause/resume round-trip.
expect(page).not_to have_text 'Paused'

click_button 'Pause'
expect(page).to have_text 'Paused'

click_button 'Resume'
expect(page).not_to have_text 'Paused'

모달이나 애니메이션이 있는 다른 컨테이너가 열림 전환을 마칠 때까지 기다린 후에 그 안의 컨트롤과 상호작용합니다. 컴포넌트는 전환 중에 컨트롤을 비활성화할 수 있습니다. 예를 들어 보이는 모달을 기다린 후에 닫기 버튼을 클릭합니다.

expect(page).to have_css('.modal.show')

within('.modal') { click_button 'Close' }

모달 헬퍼는 모달과 상호작용하기를 참고합니다.

두 번 방문하지 말고 let 재정의로 설정 변경하기#

:js 기능 스펙에서 중첩된 컨텍스트는 페이지를 방문하기 전에 실행되어야 하는 서로 다른 설정이 필요한 경우가 많습니다. 예를 들면 로그인한 사용자가 다르거나, 레코드 상태가 다르거나, 기능 플래그 상태가 다른 경우입니다. 이러한 설정을 중첩된 컨텍스트 자체의 before 블록에 추가하고 같은 URL에 visit을 두 번째로 호출하지 않습니다.

추가 visit은 같은 페이지를 다시 로드하므로 스펙이 느려집니다.

또한 페이지를 이미 방문한 후에 sign_in을 호출하면 경합이 발생합니다. sign_in 헬퍼가 Warden.on_next_request를 사용해 세션을 주입하는데, 처음 방문한 페이지에서 아직 진행 중인 백그라운드 요청이 그 주입을 소비할 수 있어 의도한 사용자가 로그인되지 않은 상태로 남기 때문입니다.

잘못된 예:

before do
  sign_in(maintainer)
  visit path
end

context 'when the user is a guest' do
  before do
    guest = create(:user, guest_of: project)
    gitlab_sign_out
    sign_in(guest)
    visit path
  end
end

대신 가장 바깥쪽 before 블록에 sign_in과 visit을 하나씩만 두고, let 정의에서 값을 읽어 설정이 동적으로 바뀌게 합니다. 그러면 중첩된 컨텍스트는 관련된 let만 재정의합니다.

올바른 예:

let(:current_user) { maintainer }

before do
  sign_in(current_user)
  visit path
end

context 'when the user is a guest' do
  let(:current_user) { create(:user, guest_of: project) }
end

이렇게 하면 스펙이 더 빨라지고 로그인 경합을 피할 수 있습니다.

wait_for로 브라우저 부수 효과 폴링하기#

동작이 완료되었음을 확인할 때는 항상 눈에 보이는 UI 결과(have_content, have_current_path, have_css)를 단언하는 것을 우선합니다. wait_for 헬퍼(spec/support/helpers/wait_helpers.rb에 정의됨)는 UI에서 단언할 수 있는 것이 없을 때 최후의 수단으로만 사용합니다. 일부 부수 효과에는 눈에 보이는 신호가 없습니다.

  • XHR이나 fetch 요청이 아닌, 브라우저가 시작한 다운로드.
  • 요소가 아직 상호작용할 수 없어서 재시도해야 하는 상호작용 (예: 페이지가 완전히 로드될 때까지 diff 줄 위에 마우스를 올려도 아무 일도 일어나지 않음).

(여기서도 wait_for_requests를 사용하지 않습니다. 이 헬퍼는 더 이상 사용되지 않으며 새 스펙에서 사용해서는 안 됩니다.)

wait_for는 기본적으로 0.01초마다 폴링합니다. 데이터베이스 기반 조건에서 과도한 부하를 피하려면 더 긴 polling_interval을 사용합니다. 작업에 정말로 더 많은 시간이 필요한 경우에만 max_wait_time을 늘립니다.

wait_for('issues sort preference to be saved',
  max_wait_time: 2 * Capybara.default_max_wait_time, polling_interval: 0.1) do
  user.reload.user_preference.issues_sort == 'updated_desc'
end
# Bad: click_link triggers a browser-initiated download and returns before the
# request is logged, so `artifact_request` can be nil.
requests = inspect_requests { click_link 'Download' }
artifact_request = requests.find { |r| r.url.include?('artifacts/download') }

# Good: poll until the download request has been recorded.
requests = inspect_requests do
  click_link 'Download'

  wait_for('artifact download request') do
    Gitlab::Testing::RequestInspectorMiddleware.requests.any? do |r|
      r.url.include?('artifacts/download')
    end
  end
end
artifact_request = requests.find { |r| r.url.include?('artifacts/download') }

대상이 아직 상호작용할 수 없어서 상호작용을 재시도해야 한다면 전체 시퀀스를 wait_for로 감싸고 Capybara::ElementNotFound를 rescue합니다. 아래 예시에서는 페이지 로드가 끝날 때까지 diff 줄 위에 마우스를 올려도 아무 일도 일어나지 않으므로, 클릭만이 아니라 마우스 올리기도 재시도해야 합니다.

# Bad: hovering an unloaded diff line is a no-op, so the button never appears.
line_holder.hover
line[:num].find('.js-add-diff-note-button').click

# Good: retry the hover until the note form appears and is focused.
wait_for('note form to appear') do
  line_holder.hover
  line[:num].find('.js-add-diff-note-button', wait: 0.2).click
  page.has_field?('note_note', focused: true, wait: 0.2)
rescue Capybara::ElementNotFound
end

요소 값을 읽지 말고 대기 매처 사용하기#

요소에서 직접 값, 텍스트 또는 개수를 읽거나(find(...).value, find(...).text, all(...).count) 페이지에서 읽으면(page.current_url) 바로 그 순간의 상태를 가져옵니다. 페이지가 비동기 업데이트를 아직 렌더링하는 중이라면 이전 값을 읽게 되어 테스트가 잘못된 이유로 실패하거나 통과합니다. 반면 have_* 매처는 기대 조건이 충족될 때까지(또는 대기 시간이 초과될 때까지) 재시도하므로 UI와 경합하지 않고 동기화됩니다.

값을 읽어 비교하지 말고 대기 매처로 단언합니다.

# Bad: reads the field value now and compares; races an in-flight update.
expect(find('#cadence-title').value).to eq(cadence.title)

# Good: waits for the field to have the expected value.
expect(page).to have_field('cadence-title', with: cadence.title)
# Bad: all_by_testid returns as soon as one match exists, then compares count.
expect(all_by_testid('cache-entry-row').count).to eq(cache_entries.size)

# Good: waits for the DOM to have the expected number of matches.
expect(page).to have_selector('[data-testid="cache-entry-row"]', count: cache_entries.size)
# Bad: reads the element's text content at this point in time.
expect(find_by_testid("user-project-count-#{admin.id}").text).to eq('1')

# Good: waits for the element to have the expected content.
within_testid("user-project-count-#{admin.id}") do
  expect(page).to have_content('1')
end

expect(find(selector).visible?).to be(true)를 단언하지 않습니다. find가 이미 보이는 요소를 기다리므로, 이 단언으로는 이후의 업데이트를 검증할 수 없습니다. 필요한 상태에 맞는 대기 매처를 사용합니다.

# Bad: reads text immediately after finding the element.
expect(find('.event-title').text).to eq('joined project GitLab')

# Good: waits for the expected exact text.
expect(page).to have_selector('.event-title', exact_text: 'joined project GitLab')

page.current_url 또는 대기하지 않는 다른 세션 값을 읽기 전에 페이지 상태를 기다립니다. 예를 들어 URL을 읽기 전에 페이지별 요소를 단언합니다.

expect(page).to have_testid('board-card')
expect(CGI.unescape(page.current_url)).to include(CGI.unescape(board_path))

스크립트는 기다리지 않음#

evaluate_script와 execute_script는 한 시점의 브라우저를 읽거나 수정합니다. 비동기 업데이트를 기다리지 않습니다. 스크립트로 상태를 읽기 전에 눈에 보이는 결과를 단언하거나 wait_for를 사용합니다.

관련된 스크립트 값이 같은 브라우저 상태를 나타내야 한다면 별도로 호출하지 말고 evaluate_script 한 번으로 가져옵니다.

wait_for('note to be scrolled into view') do
  page.evaluate_script("document.querySelector('.js-static-panel-inner').scrollTop") > 0
end

panel_scroll_top, note_position_top = page.evaluate_script(<<~JS)
  const panel = document.querySelector('.js-static-panel-inner');
  const note = document.querySelector('#note_1');
  [panel.scrollTop, note.getBoundingClientRect().top + panel.scrollTop]
JS

공유 예제에서 준비 상태 게이트 두기#

여러 스펙이 같은 페이지나 컴포넌트를 사용한다면, 준비 상태 단언을 모든 호출자에서 반복하지 말고 공유 예제에 둡니다. 그러면 이 단언이 모든 사용처의 동기화 계약이 됩니다. 예를 들어 Rapid Diffs는 diff-file-mounted 센티널을 사용해 모든 diff 파일이 마운트될 때까지 기다립니다.

before do
  page.assert_selector('diff-file-mounted', count: diffs.diff_files.size, visible: :all)
end

센티널에 관한 자세한 내용은 Rapid Diffs를 참고합니다.

UI가 아닌 메타데이터로 관리자 모드 진입하기#

enable_admin_mode!(admin, use_ui: true)로 브라우저 UI를 통해 관리자 모드를 조작하면 느리고 경합이 생깁니다. 다음 동작이 실행되기 전에 모드 활성화 요청이 완료되지 않을 수 있기 때문입니다. 대신 :enable_admin_mode RSpec 메타데이터 태그를 사용합니다. 이 태그는 UI 상호작용 없이 세션 수준에서 관리자 모드를 활성화합니다.

# Bad: slow and race-prone.
before do
  enable_admin_mode!(admin, use_ui: true)
end

# Good: activates admin mode without touching the browser.
it 'does something as admin', :enable_admin_mode do
  ...
end

# Or on a describe/context block:
context 'when in admin mode', :enable_admin_mode do
  ...
end

비용이 큰 외부 작업 모킹하기#

실제 외부 프로세스(바이너리 컴파일, Git 명령 실행, 네트워크 호출)를 유발하는 기능 스펙과 통합 스펙은, 테스트 대상 로직이 그 프로세스가 실제일 필요가 없더라도 그 프로세스의 전체 실제 소요 시간 비용을 그대로 떠안습니다.

프로덕션 코드베이스 수정 사례는 다음과 같습니다.

  • 실제 Go 컴파일을 유발하는 스펙은 실행할 때마다 약 3분이 추가되었습니다.
  • 실제 Git 명령을 실행하는 스펙은 예제마다 약 10초가 추가되었습니다.

두 경우 모두 테스트 대상 로직은 이미 단위 테스트로 다루고 있었습니다.

셸 명령을 실행하거나, 컴파일하거나, 외부 서비스를 호출하는 before 블록이나 let 정의를 찾습니다. allow / expect(...).to receive(...) 스텁이나 RSpec 더블을 사용해 대신 현실적인 픽스처를 반환하게 합니다. let_it_be와 호환되는 스텁 방식은 팩토리 안에서 메서드 스텁하기를 참고합니다.

단위 테스트가 이미 외부 작업의 출력을 검증한다면, 상위 수준 스펙에서는 이를 스텁합니다. 외부 프로세스를 유발하는 느린 before(:all) 또는 let_it_be는 컨텍스트의 모든 예제에 걸쳐 그 비용이 배가됩니다.

막혔을 때#

느린 백엔드 스펙을 리팩터링하는 데 도움을 줄 수 있는 사람을 나열하기 위해 backend_testing_performance 도메인 전문성이 있습니다.

도움을 줄 수 있는 사람을 찾으려면 Engineering Projects 페이지에서 backend testing performance를 검색하거나, www-gitlab-org 프로젝트에서 직접 찾아봅니다.

기능 카테고리 메타데이터#

각 RSpec 예제에 기능 카테고리 메타데이터를 설정해야 합니다.

EE 라이선스에 따른 테스트#

컨텍스트/스펙 블록에 if: Gitlab.ee? 또는 unless: Gitlab.ee?를 사용하면 FOSS_ONLY=1로 실행하는지에 따라 테스트를 실행할 수 있습니다.

SaaS에 따른 테스트#

SaaS 전용 기능을 테스트하는 방법에 관한 포괄적인 안내는 SaaS 전용 기능 테스트 가이드를 참고합니다.

커버리지#

simplecov는 코드 테스트 커버리지 보고서를 생성하는 데 사용됩니다. 이 보고서는 CI에서 자동으로 생성되지만 로컬에서 테스트를 실행할 때는 생성되지 않습니다. 사용자의 머신에서 스펙 파일을 실행할 때 부분 보고서를 생성하려면 SIMPLECOV 환경 변수를 설정합니다.

SIMPLECOV=1 bundle exec rspec spec/models/repository_spec.rb

커버리지 보고서는 애플리케이션 루트의 coverage 폴더에 생성되며, 예를 들어 다음과 같이 브라우저에서 열 수 있습니다.

firefox coverage/index.html

커버리지 보고서를 사용해 테스트가 코드의 100%를 다루는지 확인합니다.

뷰 스펙#

spec/views/와 ee/spec/views/의 뷰 스펙은 렌더링된 HTML 출력을 검증합니다. 백엔드 로직이나 데이터베이스 동작을 다시 테스트해서는 안 됩니다.

단언은 have_content, have_css, have_selector, have_link 같은 매처를 사용해 렌더링된 출력을 대상으로 해야 합니다. 내부 Ruby 상태나 뷰 헬퍼 메서드의 반환값을 단언하지 않습니다.

스펙에 영속화된 상태가 정말로 필요한 경우가 아니라면 설정에는 create 대신 build_stubbed를 사용해야 합니다. 인스턴스 변수를 전달할 때는 assign을, 헬퍼 메서드를 스텁할 때는 allow(view).to receive(...)를 사용합니다. 설정은 단언하려는 대상에 비례하게 유지합니다. 인스턴스 변수를 많이 할당하고 헬퍼 여러 개를 스텁하면서 요소 하나만 단언하는 스펙은 뷰의 책임이 너무 많다는 신호입니다.

뷰 스펙에는 다음을 포함하지 않습니다.

  • ActiveRecord::QueryRecorder 또는 exceed_query_limit 단언. 쿼리 성능은 뷰 스펙이 아니라 요청 스펙이나 컨트롤러 스펙에서 다룹니다.
  • receive_message_chain 같은 깊은 서비스 객체 모킹 체인. 뷰에 이런 종류의 스텁이 필요하다면 뷰 자체에 로직이 너무 많이 들어 있는 것입니다.

시스템 / 기능 테스트#

Note

새 시스템 테스트를 작성하기 전에 시스템 테스트 사용에 관한 이 가이드를 고려합니다.

  • 기능 스펙은 spec/features/에 두고, EE 전용 기능이라면 ee/spec/features/에 둡니다. 엔드 투 엔드 스펙은 qa/에 별도로 있습니다.
  • 기능 스펙 이름은 user_changes_password_spec.rb처럼 ROLE_ACTION_spec.rb 형식으로 지정해야 합니다.
  • 성공 경로와 실패 경로를 설명하는 시나리오 제목을 사용합니다.
  • "successfully"처럼 정보를 더하지 않는 시나리오 제목은 피합니다.
  • 기능 제목을 반복하는 시나리오 제목은 피합니다.
  • 데이터베이스에는 필요한 레코드만 생성합니다.
  • 정상 경로와 덜 정상적인 경로 하나씩만 테스트하고 그 이상은 하지 않습니다.
  • 그 밖의 가능한 모든 경로는 단위 테스트나 통합 테스트로 테스트해야 합니다.
  • ActiveRecord 모델의 내부가 아니라 페이지에 표시되는 내용을 테스트합니다. 예를 들어 레코드가 생성되었는지 확인하려면 Model.count가 1 늘었는지가 아니라 속성이 페이지에 표시되는지에 대한 기대 조건을 추가합니다.
  • 테스트가 UI 동작 후에 백엔드나 모델 상태를 단언해야 한다면, 먼저 눈에 보이는 성공 표시(have_content, have_current_path, have_css)를 기다린 후에만 model.reload를 읽습니다. Capybara 동작은 요청이 전달되면 반환되며 요청이 완료되면 반환되는 것이 아니므로, 그 직후에 모델 상태를 읽으면 요청과 경합합니다. 이것이 기능 스펙이 불안정해지는 가장 흔한 원인입니다.
  • DOM 요소를 찾는 것은 괜찮지만, 테스트가 더 취약해지므로 남용하지 않습니다.

UI 테스트#

UI를 테스트할 때는 사용자가 보는 것과 UI와 상호작용하는 방식을 시뮬레이션하는 테스트를 작성합니다. 이는 Capybara의 시맨틱 메서드를 우선 사용하고 ID, 클래스, 속성으로 쿼리하는 것을 피한다는 뜻입니다.

이렇게 테스트하면 다음과 같은 이점이 있습니다.

  • 모든 상호작용 요소에 접근 가능한 이름이 있음을 보장합니다.
  • 더 자연스러운 언어를 사용하므로 가독성이 높아집니다.
  • 사용자에게 보이지 않는 ID, 클래스, 속성으로 쿼리하는 것을 피하므로 덜 취약합니다.

ID, 클래스 이름, data-testid가 아니라 요소의 텍스트 레이블로 쿼리하기를 강력히 권장합니다.

필요하다면 within을 사용해 페이지의 특정 영역 안으로 상호작용 범위를 좁힐 수 있습니다. 범위를 div 같은 요소로 좁히게 될 가능성이 높고 이런 요소에는 대개 레이블이 없으므로, 이 경우에는 data-testid 선택자를 사용해도 됩니다.

be_axe_clean 매처를 사용하면 기능 테스트에서 axe 자동 접근성 테스트를 실행할 수 있습니다.

외부화된 콘텐츠#

RSpec 테스트에서 외부화된 콘텐츠에 대한 기대 조건은 번역과 일치시키기 위해 같은 외부화 메서드를 호출해야 합니다. 예를 들어 Ruby에서는 _ 메서드를 사용해야 합니다.

자세한 내용은 Internationalization for GitLab - Test files (RSpec)를 참고합니다.

동작#

가능하면 아래와 같은 더 구체적인 동작을 사용합니다.

# good
click_button _('Submit review')

click_link _('UI testing docs')

fill_in _('Search projects'), with: 'gitlab' # fill in text input with text

select _('Updated date'), from: 'Sort by' # select an option from a select input

check _('Checkbox label')
uncheck _('Checkbox label')

choose _('Radio input label')

attach_file(_('Attach a file'), '/path/to/file.png')

# bad - interactive elements must have accessible names, so
# we should be able to use one of the specific actions above
find('.group-name', text: group.name).click
find('.js-show-diff-settings').click
find('[data-testid="submit-review"]').click
find('input[type="checkbox"]').click
find('.search').native.send_keys('gitlab')
파인더#

가능하면 아래와 같은 더 구체적인 파인더를 사용합니다.

# good
find_button _('Submit review')
find_button _('Submit review'), disabled: true

find_link _('UI testing docs')
find_link _('UI testing docs'), href: docs_url

find_field _('Search projects')
find_field _('Search projects'), with: 'gitlab' # find the input field with text
find_field _('Search projects'), disabled: true
find_field _('Checkbox label'), checked: true
find_field _('Checkbox label'), unchecked: true

# acceptable when finding a element that is not a button, link, or field
find_by_testid('element')
.first 또는 블록 반복과 함께 all() 사용 피하기

all()은 컬렉션을 반환하지만 선택자를 찾지 못해도 예외를 발생시키지 않으며, Capybara의 스마트 대기의 이점도 얻지 못합니다. 그래서 오류가 나기 쉽고 느립니다.

패턴 1 - all().first는 조용히 실패합니다.

# Avoid: silent no-op if selector not found; slower than find()
all('[data-testid="download-dropdown"]').first do |button|
  button.find_by_testid('base-dropdown-toggle').click
  expect(page).to have_link format, href: uri.to_s
end

# Prefer: find() raises immediately with a clear error message if not found
find('[data-testid="unique-download-dropdown"]') do |button|
  button.find_by_testid('base-dropdown-toggle').click
  expect(page).to have_link format, href: uri.to_s
end

# Even better:
within_testid('unique-download-dropdown') do
  find_by_testid('base-dropdown-toggle').click
end

expect(page).to have_link format, href: uri.to_s

패턴 2 - 블록 반복과 함께 all()을 사용해 하위 선택자로 필터링합니다.

# Avoid: iterates every card, calling has_selector? on each, very slow and not robust
card = all("[data-testid='security-testing-card']").find do |node|
  node.has_selector?('h3', text: title, exact_text: true)
end

# Prefer: single CSS child selector query, then walk up to the parent
card = find("[data-testid='security-testing-card'] h3", text: title, exact_text: true)
          .ancestor("[data-testid='security-testing-card']")

알고 있는 하위 요소의 텍스트로 상위 요소를 찾아야 한다면, CSS 하위 선택자로 하위 요소를 먼저 찾은 다음 .ancestor()를 호출해 위로 올라갑니다.

매처#

가능하면 아래와 같은 더 구체적인 매처를 사용합니다.

# good
expect(page).to have_button _('Submit review')
expect(page).to have_button _('Submit review'), disabled: true
expect(page).to have_button _('Notifications'), class: 'is-checked' # assert the "Notifications" GlToggle is checked

expect(page).to have_link _('UI testing docs')
expect(page).to have_link _('UI testing docs'), href: docs_url # assert the link has an href

expect(page).to have_field _('Search projects')
expect(page).to have_field _('Search projects'), disabled: true
expect(page).to have_field _('Search projects'), with: 'gitlab' # assert the input field has text

expect(page).to have_checked_field _('Checkbox label')
expect(page).to have_unchecked_field _('Radio input label')

expect(page).to have_select _('Sort by')
expect(page).to have_select _('Sort by'), selected: 'Updated date' # assert the option is selected
expect(page).to have_select _('Sort by'), options: ['Updated date', 'Created date', 'Due date'] # assert an exact list of options
expect(page).to have_select _('Sort by'), with_options: ['Created date', 'Due date'] # assert a partial list of options

expect(page).to have_text _('Some paragraph text.')
expect(page).to have_text _('Some paragraph text.'), exact: true # assert exact match

expect(page).to have_current_path 'gitlab/gitlab-test/-/issues'

expect(page).to have_title _('Not Found')

# acceptable when a more specific matcher above is not possible
expect(page).to have_css 'h2', text: 'Issue title'
expect(page).to have_css 'p', text: 'Issue description', exact: true
expect(page).to have_css '[data-testid="weight"]', text: 2
expect(page).to have_css '.atwho-view ul', visible: true
모달과 상호작용하기#

GitLab UI 모달과 상호작용하려면 within_modal 헬퍼를 사용합니다.

include Spec::Support::Helpers::ModalHelpers

within_modal do
  expect(page).to have_link _('UI testing docs')

  fill_in _('Search projects'), with: 'gitlab'

  click_button 'Continue'
end

또한 수락만 하면 되는 확인 모달에는 accept_gl_confirm을 사용할 수 있습니다. window.confirm()을 confirmAction으로 마이그레이션할 때 유용합니다.

include Spec::Support::Helpers::ModalHelpers

accept_gl_confirm do
  click_button 'Delete user'
end

accept_gl_confirm에 기대하는 확인 메시지와 버튼 텍스트를 전달할 수도 있습니다.

include Spec::Support::Helpers::ModalHelpers

accept_gl_confirm('Are you sure you want to delete this user?', button_text: 'Delete') do
  click_button 'Delete user'
end
그 밖의 유용한 메서드#

파인더 메서드로 요소를 가져온 후에는 hover 같은 요소 메서드를 여러 개 호출할 수 있습니다.

Capybara 테스트에는 accept_confirm 같은 세션 메서드도 여러 개 있습니다.

그 밖의 유용한 메서드는 아래와 같습니다.

refresh # refresh the page

send_keys([:shift, 'i']) # press Shift+I keys to go to the Issues dashboard page

current_window.resize_to(1000, 1000) # resize the window

scroll_to(find_field('Comment')) # scroll to an element

spec/support/helpers/ 디렉터리에서 GitLab 커스텀 헬퍼도 여러 개 찾을 수 있습니다.

라이브 디버그#

브라우저 동작을 관찰하면서 Capybara 테스트를 디버그해야 할 때가 있습니다.

스펙에서 live_debug 메서드를 사용하면 Capybara를 일시 중지하고 브라우저에서 웹사이트를 볼 수 있습니다. 현재 페이지가 기본 브라우저에서 자동으로 열립니다. 먼저 로그인해야 할 수 있습니다(현재 사용자의 자격 증명이 터미널에 표시됩니다).

테스트 실행을 재개하려면 아무 키나 누릅니다.

예를 들면 다음과 같습니다.

$ bin/rspec spec/features/auto_deploy_spec.rb:34
Running via Spring preloader in process 8999
Run options: include {:locations=>{"./spec/features/auto_deploy_spec.rb"=>[34]}}

Current example is paused for live debugging
The current user credentials are: user2 / 12345678
Press any key to resume the execution of the example!
Back to the example!
.

Finished in 34.51 seconds (files took 0.76702 seconds to load)
1 example, 0 failures

live_debug는 JavaScript가 활성화된 스펙에서만 작동합니다.

표시되는 브라우저에서 :js 스펙 실행#

다음과 같이 WEBDRIVER_HEADLESS=0으로 스펙을 실행합니다.

WEBDRIVER_HEADLESS=0 bin/rspec some_spec.rb

테스트는 빠르게 완료되지만, 무슨 일이 일어나는지 파악하는 데 도움이 됩니다. live_debug를 WEBDRIVER_HEADLESS=0과 함께 사용하면 열려 있는 브라우저가 일시 중지되며, 페이지를 다시 열지 않습니다. 이를 이용해 요소를 디버그하고 검사할 수 있습니다.

byebug 또는 binding.pry를 추가해 실행을 일시 중지하고 테스트를 단계별로 실행할 수도 있습니다.

스크린샷#

capybara-screenshot gem을 사용해 실패할 때 자동으로 스크린샷을 찍습니다. CI에서는 이 파일을 job 아티팩트로 다운로드할 수 있습니다.

실패한 :js 스펙을 분류할 때는 예외 메시지와 백트레이스를 추적하기 전에 스크린샷을 확인합니다. 실패 시점의 페이지 상태(예상하지 못한 빈 상태, 화면을 가리는 모달, 이전 예제에서 남은 오래된 페이지)가 실제 원인인 경우가 많고, 예외 텍스트만으로는 관련 없는 세부 사항으로 이끌릴 수 있습니다. 이를 돕기 위해 스크린샷 경로는 놓칠 수 있는 별도 단계가 아니라 실패 자체의 Failure/Error: 메시지 바로 아래에 출력됩니다.

또한 아래 메서드를 추가해 테스트의 어느 지점에서든 직접 스크린샷을 찍을 수 있습니다. 더 이상 필요하지 않으면 반드시 제거합니다. 자세한 내용은 https://github.com/mattheworiordan/capybara-screenshot#manual-screenshots를 참고합니다.

:js 스펙에 screenshot_and_save_page를 추가하면 Capybara가 "보는" 화면의 스크린샷을 찍고 페이지 소스를 저장합니다.

:js 스펙에 screenshot_and_open_image를 추가하면 Capybara가 "보는" 화면의 스크린샷을 찍고 이미지를 자동으로 엽니다.

이렇게 생성된 HTML 덤프에는 CSS가 없습니다. 그래서 실제 애플리케이션과 매우 다르게 보입니다. 디버깅을 더 쉽게 해 주는 CSS를 추가하는 간단한 방법이 있습니다.

빠른 단위 테스트#

일부 클래스는 Rails와 잘 분리되어 있습니다. 이런 클래스는 Rails 환경과 Bundler의 :default 그룹 gem 로딩으로 인한 오버헤드 없이 테스트할 수 있어야 합니다. 이 경우 테스트 파일에서 require 'spec_helper' 대신 require 'fast_spec_helper'를 사용할 수 있으며, 다음 이유로 테스트가 매우 빠르게 실행됩니다.

  • gem 로딩을 건너뜁니다.
  • Rails 앱 부팅을 건너뜁니다.
  • GitLab Shell과 Gitaly 설정을 건너뜁니다.
  • 테스트 리포지터리 설정을 건너뜁니다.

fast_spec_helper를 사용하는 테스트는 로드하는 데 약 1초가 걸리며, 일반 spec_helper는 30초 이상 걸립니다.

fast_spec_helper는 lib/ 디렉터리 안에 있는 클래스의 자동 로딩도 지원합니다. 클래스나 모듈이 lib/ 디렉터리의 코드만 사용한다면 의존성을 명시적으로 로드할 필요가 없습니다. fast_spec_helper는 Rails 환경에서 흔히 사용하는 코어 확장을 포함해 모든 ActiveSupport 확장도 로드합니다.

경우에 따라 코드가 gem을 사용하거나 의존성이 lib/에 없으면 require_dependency로 일부 의존성을 로드해야 할 수도 있습니다.

예를 들어 내부적으로 re2 라이브러리를 사용하는 Gitlab::UntrustedRegexp 클래스를 호출하는 코드를 테스트하려면 다음 중 하나를 해야 합니다.

  • re2 gem이 필요한 라이브러리 파일에 require_dependency 're2'를 추가해 이 요구 사항을 명시적으로 드러냅니다. 이 방법을 권장합니다.
  • 스펙 자체에 추가합니다.

또는 도메인의 여러 fast_spec_helper 스펙에서 필요한 의존성이고 수동으로 여러 번 추가하고 싶지 않다면, fast_spec_helper 자체에서 직접 호출되도록 추가할 수 있습니다. 이를 위해 spec/support/fast_spec/YOUR_DOMAIN/fast_spec_helper_support.rb 파일을 만들고 fast_spec_helper에서 require합니다. 따라 할 수 있는 기존 예시가 있습니다.

RuboCop 관련 스펙에는 rubocop_spec_helper를 사용합니다.

Warning

코드와 스펙이 Rails와 잘 분리되어 있는지 확인하려면 bin/rspec으로 스펙을 개별 실행합니다. bin/spring rspec은 spec_helper를 자동으로 로드하므로 사용하지 않습니다.

fast_spec_helper 스펙 유지 관리#

fast_spec_helper를 사용하는 모든 스펙을 여러 방식으로 실행하는 데 사용할 수 있는 유틸리티 스크립트 scripts/run-fast-specs.sh가 있습니다. 이 스크립트는 격리된 상태에서 성공적으로 실행되지 않는 등 문제가 있는 fast_spec_helper 스펙을 식별하는 데 유용합니다. 자세한 내용은 스크립트를 참고합니다.

subject와 let 변수#

GitLab RSpec 스위트는 중복을 줄이기 위해 let(그리고 그 엄격한 비지연 버전인 let!) 변수를 광범위하게 사용해 왔습니다. 그러나 이는 때때로 명확성을 대가로 치르므로, 앞으로 사용에 관한 몇 가지 지침을 정해야 합니다.

  • 컨텍스트마다 let 정의를 반복하는 대신 표 기반 / 매개변수화 테스트를 우선 사용합니다.
  • let! 변수는 인스턴스 변수보다 낫습니다. let 변수는 let! 변수보다 낫습니다. 로컬 변수는 let 변수보다 낫습니다.
  • 스펙 파일 전체의 중복을 줄이려면 let을 사용합니다.
  • 단일 테스트에서만 사용하는 변수를 정의하려고 let을 사용하지 않습니다. 테스트의 it 블록 안에 로컬 변수로 정의합니다.
  • 더 깊이 중첩된 context 또는 describe 블록에서만 사용하는 let 변수를 최상위 describe 블록 안에 정의하지 않습니다. 정의를 사용하는 곳과 가능한 한 가깝게 둡니다.
  • 한 let 변수의 정의를 다른 let 변수로 재정의하는 것을 피합니다.
  • 다른 let 변수의 정의에서만 사용하는 let 변수를 정의하지 않습니다. 대신 헬퍼 메서드를 사용합니다.
  • let! 변수는 정해진 순서로 엄격하게 평가해야 하는 경우에만 사용해야 하며, 그렇지 않으면 let으로 충분합니다. let은 지연 평가되며 참조되기 전까지는 평가되지 않는다는 점을 기억합니다.
  • 예제에서 subject를 참조하는 것을 피합니다. 변수가 문맥에 맞는 이름을 갖도록 이름 있는 subject subject(:name) 또는 let 변수를 대신 사용합니다.

표 기반 / 매개변수화 테스트#

이 방식의 테스트는 하나의 코드를 포괄적인 범위의 입력으로 실행하는 데 사용합니다. 테스트 케이스를 한 번만 지정하고 입력 표와 각 입력에 대한 기대 출력을 함께 두면, 테스트를 더 읽기 쉽고 더 간결하게 만들 수 있습니다.

RSpec::Parameterized gem을 사용합니다.

let 값만 다른 여러 context 블록보다 표 기반 테스트를 우선 사용합니다. 예를 들어 다음과 같이 컨텍스트를 반복하는 대신

# bad
context 'when the group status is :active' do
  let(:status) 
  let(:actor) { create(:group) }

  it { expect(actor.visible?).to be(true) }
end

context 'when the project status is :active' do
  let(:status) 
  let(:actor) { create(:project) }

  it { expect(actor.visible?).to be(true) }
end

context 'when the group status is :inactive' do
  let(:status) 
  let(:actor) { create(:group) }

  it { expect(actor.visible?).to be(false) }
end

context 'when the project status is :inactive' do
  let(:status) 
  let(:actor) { create(:project) }

  it { expect(actor.visible?).to be(false) }
end

표를 사용하면 같은 케이스를 더 간결하게 표현할 수 있습니다.

# good
using RSpec::Parameterized::TableSyntax

let(:group) { create(:group) }
let(:project) { create(:project) }

where(:actor, :status, :visible) do
  ref(:group) | :active   | true
  ref(:group) | :inactive | false
  ref(:project) | :active   | true
  ref(:project) | :inactive | false
end

with_them do
  it { expect(actor.visible?).to be(visible) }
end

표 기반 테스트를 만든 후 다음과 같은 오류가 표시된다면

NoMethodError:
  undefined method `to_params'

  param_sets = extracted.is_a?(Array) ? extracted : extracted.to_params
                                                                       ^^^^^^^^^^
  Did you mean?  to_param

스펙 파일에 using RSpec::Parameterized::TableSyntax 줄을 포함해야 한다는 뜻입니다.

Warning

where 블록의 입력에는 단순한 값만 사용합니다. proc, 상태를 가진 객체, FactoryBot으로 생성한 객체 등을 사용하면 예기치 않은 결과가 발생할 수 있습니다. 대신 ref(:symbol)을 사용합니다.

공통 테스트 설정#

Note

let_it_be와 before_all은 DatabaseCleaner의 삭제 전략과 함께 작동하지 않습니다. 여기에는 마이그레이션 스펙, Rake 태스크 스펙, :delete RSpec 메타데이터 태그가 있는 스펙이 포함됩니다. 자세한 내용은 이슈 420379를 참고합니다.

경우에 따라 예제마다 테스트용 동일한 객체를 다시 생성할 필요가 없습니다. 예를 들어 같은 프로젝트의 이슈를 테스트하려면 프로젝트와 그 프로젝트의 게스트가 필요하므로, 파일 전체에 프로젝트와 사용자 하나씩이면 충분합니다.

가능하면 before(:all) 또는 before(:context)로 이를 구현하지 않습니다. 그렇게 하면 이 훅은 데이터베이스 트랜잭션 밖에서 실행되므로 데이터를 수동으로 정리해야 합니다.

대신 test-prof gem의 let_it_be 변수와 before_all 훅을 사용해 이를 달성할 수 있습니다.

let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) }

before_all do
  project.add_guest(user)
end

그러면 이 컨텍스트에서 Project, User, ProjectMember가 각각 하나만 생성됩니다.

let_it_be와 before_all은 중첩된 컨텍스트에서도 사용할 수 있습니다. 컨텍스트가 끝난 후의 정리는 트랜잭션 롤백으로 자동 처리됩니다.

let_it_be 블록 안에 정의된 객체를 수정하는 경우 다음 중 하나를 해야 합니다.

  • 필요에 따라 객체를 다시 로드합니다.
  • let_it_be_with_reload 별칭을 사용합니다.
  • reload 옵션을 지정해 모든 예제마다 다시 로드합니다.
let_it_be_with_reload(:project) { create(:project) }
let_it_be(:project, reload: true) { create(:project) }

let_it_be_with_refind 별칭을 사용하거나 refind 옵션을 지정해 새 객체를 완전히 로드할 수도 있습니다.

let_it_be_with_refind(:project) { create(:project) }
let_it_be(:project, refind: true) { create(:project) }

let_it_be는 allow 같은 스텁이 있는 팩토리와 함께 사용할 수 없습니다. let_it_be는 before(:all) 블록에서 실행되고, RSpec은 before(:all)에서 스텁을 허용하지 않기 때문입니다. 자세한 내용은 이 이슈를 참고합니다. 해결하려면 let을 사용하거나 팩토리가 스텁을 사용하지 않도록 변경합니다.

let_it_be는 before 블록에 의존해서는 안 됨#

스펙 중간에서 let_it_be를 사용할 때는 before 블록에 의존하지 않도록 합니다. let_it_be가 before(:all) 중에 먼저 실행되기 때문입니다.

이 예시에서 create(:bar)가 스텁에 의존하는 콜백을 실행했습니다.

let_it_be(:node) { create(:geo_node, :secondary) }

before do
  stub_current_geo_node(node)
end

context 'foo' do
  let_it_be(:bar) { create(:bar) }

  ...
end

create(:bar)가 실행될 때 스텁이 설정되어 있지 않으므로 테스트가 불안정합니다.

이 예시에서는 테스트별 수명 주기 밖에서 RSpec-mocks의 더블이나 부분 더블을 사용할 수 없으므로 before를 before_all로 대체할 수 없습니다.

따라서 let_it_be(:bar) 대신 let 또는 let!을 사용하는 것이 해결책입니다.

시간에 민감한 테스트#

ActiveSupport::Testing::TimeHelpers를 사용하면 시간에 민감한 항목을 검증할 수 있습니다. 시간에 민감한 항목을 실행하거나 검증하는 모든 테스트는 일시적인 테스트 실패를 방지하기 위해 이 헬퍼를 사용해야 합니다.

피해야 할 시간 관련 불안정성의 반복적인 원인 두 가지는 다음과 같습니다.

  • 테스트가 타임스탬프 칼럼으로 레코드를 정렬한다면 레코드마다 서로 다른 타임스탬프를 부여합니다(예: travel_to와 명시적 오프셋 사용). 타임스탬프가 같으면 정렬이 비결정적이 되어 단언이 불안정해집니다.
  • "오늘"을 기준으로 동작이 결정되는 스펙에 미래 날짜 상수를 하드코딩하지 않습니다. 그 날짜가 오기 전까지는 통과하다가 그 이후에는 실패합니다. travel_to를 사용하거나 Time.current를 기준으로 날짜를 계산합니다.

예시:

it 'is overdue' do
  issue = build(:issue, due_date: Date.tomorrow)

  travel_to(3.days.from_now) do
    expect(issue).to be_overdue
  end
end

RSpec 헬퍼#

:freeze_time과 :time_travel_to RSpec 메타데이터 태그 헬퍼를 사용하면 스펙 전체를 ActiveSupport::Testing::TimeHelpers 메서드로 감싸는 데 필요한 상용구 코드의 양을 줄일 수 있습니다.

describe 'specs which require time to be frozen', :freeze_time do
  it 'freezes time' do
    right_now = Time.now

    expect(Time.now).to eq(right_now)
  end
end

describe 'specs which require time to be frozen to a specific date and/or time', time_travel_to: '2020-02-02 10:30:45 -0700' do
  it 'freezes time to the specified date and time' do
    expect(Time.now).to eq(Time.new(2020, 2, 2, 17, 30, 45, '+00:00'))
  end
end

내부적으로 이 헬퍼들은 around(:each) 훅과 ActiveSupport::Testing::TimeHelpers 메서드의 블록 구문을 사용합니다.

around(:each) do |example|
  freeze_time { example.run }
end

around(:each) do |example|
  travel_to(date_or_time) { example.run }
end

예제가 실행되기 전에 생성된 객체(예: let_it_be로 생성한 객체)는 스펙 범위 밖에 있다는 점을 기억합니다. 모든 것의 시간을 동결해야 한다면 before :all을 사용해 설정까지 감쌀 수 있습니다.

before :all do
  freeze_time
end

after :all do
  unfreeze_time
end

타임스탬프 절삭#

Active Record 타임스탬프는 Rails의 ActiveRecord::Timestamp 모듈이 Time.now를 사용해 설정합니다. 시간 정밀도는 OS에 따라 다르며, 문서에 명시된 대로 소수 초를 포함할 수 있습니다.

Rails 모델이 데이터베이스에 저장될 때 모델의 타임스탬프는 PostgreSQL의 timestamp without time zone이라는 타입으로 저장되며, 이 타입은 마이크로초 해상도(소수점 이하 여섯 자리)를 가집니다. 따라서 1577987974.6472975를 PostgreSQL에 보내면 소수 부분의 마지막 자리를 잘라내고 대신 1577987974.647297을 저장합니다.

그 결과 다음과 같은 단순한 테스트가

let_it_be(:contact) { create(:contact) }

data = Gitlab::HookData::IssueBuilder.new(issue).build

expect(data).to include('customer_relations_contacts' => [contact.hook_attrs])

다음과 비슷한 오류로 실패할 수 있습니다.

expected {
"assignee_id" => nil, "...1 +0000 } to include {"customer_relations_contacts" => [{:created_at => "2023-08-04T13:30:20Z", :first_name => "Sidney Jones3" }]}

Diff:
       @@ -1,35 +1,69 @@
       -"customer_relations_contacts" => [{:created_at=>"2023-08-04T13:30:20Z", :first_name=>"Sidney Jones3" }],
       +"customer_relations_contacts" => [{"created_at"=>2023-08-04 13:30:20.245964000 +0000, "first_name"=>"Sidney Jones3" }],

해결 방법은 데이터베이스에서 객체를 .reload해 올바른 정밀도의 타임스탬프를 가져오는 것입니다.

let_it_be(:contact) { create(:contact) }

data = Gitlab::HookData::IssueBuilder.new(issue).build

expect(data).to include('customer_relations_contacts' => [contact.reload.hook_attrs])

이 설명은 Maciek Rząsa의 블로그 글에서 가져왔습니다.

이 문제가 발생한 머지 리퀘스트와 이를 논의한 백엔드 페어링 세션을 확인할 수 있습니다.

테스트의 기능 플래그#

이 섹션은 기능 플래그를 사용한 개발로 이동했습니다.

깨끗한 테스트 환경#

단일 GitLab 테스트가 실행하는 코드는 많은 데이터 항목에 접근하고 수정할 수 있습니다. 테스트가 실행되기 전의 신중한 준비와 이후의 정리가 없으면, 테스트가 이후 테스트의 동작에 영향을 주는 방식으로 데이터를 변경할 수 있습니다. 이는 어떤 경우에도 피해야 합니다. 다행히 기존 테스트 프레임워크가 대부분의 경우를 이미 처리합니다.

테스트 환경이 오염되면 흔한 결과는 불안정한 테스트입니다. 오염은 흔히 순서 의존성으로 나타납니다. 스펙 A 다음에 스펙 B를 실행하면 항상 실패하지만, 스펙 B 다음에 스펙 A를 실행하면 항상 성공합니다. 이런 경우 rspec --bisect(또는 스펙 파일의 수동 쌍별 이분 탐색)를 사용해 어느 스펙이 원인인지 확인할 수 있습니다. 문제를 해결하려면 테스트 스위트가 환경을 깨끗하게 유지하는 방법을 어느 정도 이해해야 합니다. 각 데이터 저장소에 관해 더 알아봅니다.

SQL 데이터베이스#

database_cleaner gem이 이를 관리합니다. 각 스펙은 트랜잭션으로 감싸지며, 테스트가 완료된 후 롤백됩니다. 일부 스펙은 대신 완료 후 모든 테이블에 DELETE FROM 쿼리를 실행합니다. 그러면 생성된 행을 여러 데이터베이스 연결에서 볼 수 있으며, 이는 브라우저에서 실행되는 스펙이나 마이그레이션 스펙 등에 중요합니다.

널리 알려진 TRUNCATE TABLES 방식 대신 이러한 전략을 사용하면 기본 키와 그 밖의 시퀀스가 스펙 간에 초기화되지 않는다는 결과가 따릅니다. 따라서 스펙 A에서 프로젝트를 생성한 다음 스펙 B에서 프로젝트를 생성하면, 첫 번째는 id=1이고 두 번째는 id=2입니다.

즉, 스펙은 ID 값이나 그 밖의 시퀀스로 생성되는 칼럼 값에 의존해서는 안 됩니다. 의도하지 않은 충돌을 피하기 위해 스펙은 이런 종류의 칼럼에 값을 수동으로 지정하는 것도 피해야 합니다. 대신 값을 지정하지 않고 두었다가 행이 생성된 후에 값을 조회합니다.

마이그레이션 스펙의 TestProf#

위에서 설명한 이유로 마이그레이션 스펙은 데이터베이스 트랜잭션 안에서 실행할 수 없습니다. 테스트 스위트는 TestProf를 사용해 테스트 스위트의 실행 시간을 개선하지만, TestProf는 이러한 최적화를 수행하려고 데이터베이스 트랜잭션을 사용합니다. 이 때문에 마이그레이션 스펙에서는 TestProf 메서드를 사용할 수 없습니다. 다음은 사용하지 말고 기본 RSpec 메서드로 대체해야 하는 메서드입니다.

  • let_it_be: 대신 let 또는 let!을 사용합니다.
  • let_it_be_with_reload: 대신 let 또는 let!을 사용합니다.
  • let_it_be_with_refind: 대신 let 또는 let!을 사용합니다.
  • before_all: 대신 before 또는 before(:all)을 사용합니다.

Workhorse JWT 검증#

Senddata를 내보내는 Rails 컨트롤러와 Grape API 엔드포인트는 verify_workhorse_api!를 호출해 Workhorse를 거치지 않은 요청을 거부합니다. 표준 강제 지점은 응답을 쓰는 헬퍼 자체입니다 (Rails 컨트롤러는 app/helpers/workhorse_helper.rb, Grape는 lib/api/helpers.rb이며 send_git_blob, send_git_archive, send_artifacts_entry, present_carrierwave_file!, send_workhorse_headers! 등이 있습니다). 모든 송출은 이 헬퍼 중 하나를 거치며, 헬퍼는 Gitlab-Workhorse-Send-Data 응답 헤더를 저장하기 전에 JWT를 검증합니다.

spec/support/workhorse_jwt_injection.rb의 테스트 측 메커니즘 두 가지가 CI에서 이 계약을 실행합니다.

  1. before 훅이 모든 테스트 요청에 유효한 Workhorse JWT를 주입합니다. 실제 Gitlab::Workhorse.verify_api_request!가 테스트 프로세스에서 실행되며 스텁은 없고, 정상 경로 스펙에서는 JWT 헤더를 구성할 필요가 없습니다.
  2. after 훅은 Gitlab-Workhorse-Send-Data가 송출되었지만 요청에 검증된 JWT가 없었다면 예제를 실패시킵니다. 보호된 헬퍼를 우회하는 향후 엔드포인트는 조용히 통과하는 대신 자체 정상 경로 테스트가 실패하게 됩니다.

테스트가 강제 경계 자체를 단언할 때만, 예를 들어 헤더가 없을 때 403 Forbidden 응답을 단언할 때만 예제나 컨텍스트에 :verify_workhorse_jwt 태그를 붙입니다. 이 태그는 주입과 감지를 모두 해제합니다.

context 'without Workhorse JWT', :verify_workhorse_jwt do
  it 'returns 403 forbidden' do
    get api(path)

    expect(response).to have_gitlab_http_status(:forbidden)
  end
end

일반 테스트에는 이 태그를 적용하지 않습니다. 자동 주입이 일반 테스트를 다루며, 이 태그는 테스트가 JWT가 없는 경로를 명시적으로 처리하도록 강제합니다.

Redis#

GitLab은 캐시된 항목과 Sidekiq job이라는 두 가지 주요 범주의 데이터를 Redis에 저장합니다. 별도의 Redis 인스턴스가 뒷받침하는 Gitlab::Redis::Wrapper 하위 클래스의 전체 목록을 확인합니다.

대부분의 스펙에서 Rails 캐시는 실제로 인메모리 저장소입니다. 이 저장소는 스펙 사이에 교체되므로 Rails.cache.read와 Rails.cache.write 호출은 안전합니다. 그러나 스펙이 Redis를 직접 호출한다면 어느 Redis 인스턴스를 사용하는지에 따라 :clean_gitlab_redis_cache, :clean_gitlab_redis_shared_state 또는 :clean_gitlab_redis_queues 트레이트를 스스로 표시해야 합니다.

백그라운드 job / Sidekiq#

기본적으로 Sidekiq job은 job 배열에 큐잉되며 처리되지 않습니다. 테스트가 Sidekiq job을 큐에 넣고 이를 처리해야 한다면 :sidekiq_inline 트레이트를 사용할 수 있습니다.

:sidekiq_might_not_need_inline 트레이트는 Sidekiq 인라인 모드가 페이크 모드로 변경되었을 때 Sidekiq이 실제로 job을 처리해야 했던 모든 테스트에 추가되었습니다. 이 트레이트가 있는 테스트는 Sidekiq의 job 처리에 의존하지 않도록 수정하거나, 백그라운드 job 처리가 필요하거나 기대된다면 :sidekiq_might_not_need_inline 트레이트를 :sidekiq_inline으로 업데이트해야 합니다.

perform_enqueued_jobs는 지연된 메일 전달을 테스트할 때만 유용합니다. Sidekiq 워커가 ApplicationJob / ActiveJob::Base를 상속하지 않기 때문입니다.

기능 스펙에서는 UI 동작을 유발하는 코드와 그 눈에 보이는 결과(메일 전달, 확인 모달, 업데이트된 페이지 상태)에 대한 단언을 모두 perform_enqueued_jobs 블록 안에 감쌉니다. 클릭은 나중에 job을 큐에 넣는 AJAX 요청만 시작합니다. 블록이 클릭만 감싸면 그 요청이 job을 큐에 넣기 전에 블록이 끝날 수 있으며, 그러면 job이 인라인이 아니라 기본 테스트 어댑터를 거쳐 실행되어 처리되지 않습니다. 블록 안에서 결과를 단언하면 요청이 완료되고 job이 인라인으로 실행될 때까지 블록이 열려 있게 됩니다.

# Bad: the email is not yet delivered when the assertion runs.
perform_enqueued_jobs { click_button 'Approve' }
expect(page).to have_content('Approval email sent')

# Good: the assertion runs only after the enqueued jobs complete.
perform_enqueued_jobs do
  click_button 'Approve'
  expect(page).to have_content('Approval email sent')
end

DNS#

DNS는 개발자의 로컬 네트워크에 따라 문제를 일으킬 수 있으므로 DNS 요청은 테스트 스위트 전체에서 스텁 처리됩니다 (!22368 기준). spec/support/dns.rb에 RSpec 레이블이 있으며, DNS 스텁을 우회해야 할 때 다음과 같이 테스트에 적용할 수 있습니다.

it "really connects to Prometheus", :permit_dns do

더 구체적인 제어가 필요하다면 DNS 차단은 spec/support/helpers/dns_helpers.rb에 구현되어 있으며 이 메서드들을 다른 곳에서 호출할 수 있습니다.

속도 제한#

속도 제한은 테스트 스위트에서 활성화되어 있습니다. :js 트레이트를 사용하는 기능 스펙에서 속도 제한이 발동될 수 있습니다. 대부분의 경우 스펙에 :clean_gitlab_redis_rate_limiting 트레이트를 표시하면 속도 제한 발동을 피할 수 있습니다. 이 트레이트는 스펙 사이에 Redis 캐시에 저장된 속도 제한 데이터를 지웁니다. 단일 테스트가 속도 제한을 발동시킨다면 대신 :disable_rate_limit을 사용할 수 있습니다.

File 메서드 스텁하기#

파일의 내용을 스텁해야 하는 상황에서는 File.read 스텁을 올바르게 처리하는 stub_file_read와 expect_file_read 헬퍼 메서드를 사용합니다. 이 메서드들은 지정한 파일 이름에 대해 File.read를 스텁하고, File.exist?도 true를 반환하도록 스텁합니다.

어떤 이유로든 File.read를 수동으로 스텁해야 한다면 다음을 반드시 지킵니다.

  1. 다른 파일 경로에 대해서는 원래 구현을 스텁하고 호출합니다.
  2. 그런 다음 관심 있는 파일 경로에 대해서만 File.read를 스텁합니다.

그렇지 않으면 코드베이스의 다른 부분에서 호출하는 File.read가 잘못 스텁됩니다.

# bad, all Files will read and return nothing
allow(File).to receive(:read)

# good
stub_file_read(my_filepath, content: "fake file content")

# also OK
allow(File).to receive(:read).and_call_original
allow(File).to receive(:read).with(my_filepath).and_return("fake file_content")

파일 시스템#

파일 시스템 데이터는 대략 "리포지터리"와 "그 밖의 모든 것"으로 나눌 수 있습니다. 리포지터리는 tmp/tests/repositories에 저장됩니다. 이 디렉터리는 테스트 실행이 시작되기 전과 끝난 후에 비워집니다. 스펙 사이에는 비워지지 않으므로, 생성된 리포지터리가 프로세스가 실행되는 동안 이 디렉터리에 누적됩니다. 삭제하는 데 비용이 많이 들지만, 신중하게 관리하지 않으면 오염으로 이어질 수 있습니다.

이를 피하기 위해 테스트 스위트에서는 해시 스토리지가 활성화되어 있습니다. 즉, 리포지터리에는 프로젝트 ID에 따라 결정되는 고유한 경로가 부여됩니다. 프로젝트 ID는 스펙 사이에 초기화되지 않으므로 각 스펙은 디스크에 자체 리포지터리를 갖게 되고, 스펙 사이에 변경 사항이 보이지 않게 됩니다.

스펙이 프로젝트 ID를 수동으로 지정하거나 tmp/tests/repositories/ 디렉터리의 상태를 직접 검사한다면, 실행 전과 후에 해당 디렉터리를 정리해야 합니다. 일반적으로 이러한 패턴은 완전히 피해야 합니다.

업로드처럼 데이터베이스 객체에 연결된 그 밖의 파일 종류도 일반적으로 같은 방식으로 관리됩니다. 스펙에서 해시 스토리지가 활성화되어 있으면 ID에 따라 결정되는 위치에 디스크로 기록되므로 충돌이 발생하지 않아야 합니다.

일부 스펙은 projects 팩토리에 :legacy_storage 트레이트를 전달해 해시 스토리지를 비활성화합니다. 이렇게 하는 스펙은 프로젝트나 해당 그룹의 path를 절대 재정의해서는 안 됩니다. 기본 경로에는 프로젝트 ID가 포함되므로 충돌하지 않습니다. 두 스펙이 같은 경로로 :legacy_storage 프로젝트를 생성하면 디스크의 같은 리포지터리를 사용하게 되어 테스트 환경 오염으로 이어집니다.

그 밖의 파일은 스펙이 직접 관리해야 합니다. 예를 들어 tmp/test-file.csv 파일을 생성하는 코드를 실행한다면, 스펙은 정리 과정에서 해당 파일이 제거되도록 해야 합니다.

지속되는 인메모리 애플리케이션 상태#

주어진 rspec 실행의 모든 스펙은 같은 Ruby 프로세스를 공유하므로, 스펙 사이에 접근할 수 있는 Ruby 객체를 수정해 서로 영향을 줄 수 있습니다. 실제로는 전역 변수와 상수(Ruby 클래스, 모듈 등 포함)가 이에 해당합니다.

전역 변수는 일반적으로 수정하지 않아야 합니다. 꼭 필요하다면 다음과 같은 블록을 사용해 이후 변경이 롤백되도록 할 수 있습니다.

around(:each) do |example|
  old_value = $0

  begin
    $0 = "new-value"
    example.run
  ensure
    $0 = old_value
  end
end

스펙이 상수를 수정해야 한다면 stub_const 헬퍼를 사용해 변경이 롤백되도록 해야 합니다.

ENV 상수의 내용을 수정해야 한다면 대신 stub_env 헬퍼 메서드를 사용할 수 있습니다.

대부분의 Ruby 인스턴스는 스펙 사이에 공유되지 않지만 클래스와 모듈은 일반적으로 공유됩니다. 클래스와 모듈의 인스턴스 변수, 접근자, 클래스 변수, 그 밖의 상태를 가진 관용구는 전역 변수와 같은 방식으로 다뤄야 합니다. 꼭 필요한 경우가 아니면 수정하지 않습니다. 특히 수정이 필요하지 않도록 기대 조건 또는 스텁과 함께 의존성 주입을 사용하는 것을 우선합니다. 다른 선택지가 없다면 전역 변수 예시와 같은 around 블록을 사용할 수 있지만, 가능하면 피합니다.

Elasticsearch 스펙#

Elasticsearch가 필요한 스펙에는 :elastic 또는 :elastic_delete_by_query 메타데이터를 표시해야 합니다. :elastic 메타데이터는 모든 예제 전후에 인덱스를 생성하고 삭제합니다.

:elastic_delete_by_query 메타데이터는 각 컨텍스트의 시작과 끝에서만 인덱스를 생성하고 삭제해 파이프라인 실행 시간을 줄이기 위해 추가되었습니다. 인덱스를 깨끗하게 유지하도록 예제 사이에 모든 인덱스(마이그레이션 인덱스 제외)의 데이터를 삭제하는 데 Elasticsearch delete by query API를 사용합니다.

:elastic_clean 메타데이터는 인덱스를 깨끗하게 유지하도록 예제 사이에 인덱스를 생성하고 삭제합니다. 이렇게 하면 테스트가 필수적이지 않은 데이터로 오염되지 않습니다. :elastic 또는 :elastic_delete_by_query 메타데이터를 사용할 때 문제가 발생한다면 대신 :elastic_clean을 사용합니다. :elastic_clean은 다른 트레이트보다 훨씬 느리므로 드물게 사용해야 합니다.

Elasticsearch 로직에 대한 대부분의 테스트는 다음과 관련이 있습니다.

  • PostgreSQL에 데이터를 생성하고 Elasticsearch에 인덱싱되기를 기다립니다.
  • 해당 데이터를 검색합니다.
  • 테스트가 기대한 결과를 내는지 확인합니다.

인덱스의 개별 레코드가 아니라 구조적 변경을 확인하는 경우처럼 몇 가지 예외가 있습니다.

Note

고급 검색의 인덱싱은 Gitlab::Redis::SharedState를 사용합니다. 따라서 Elasticsearch 메타데이터는 동적으로 :clean_gitlab_redis_shared_state를 사용합니다. :clean_gitlab_redis_shared_state를 수동으로 추가할 필요가 없습니다.

Elasticsearch를 사용하는 스펙에서는 다음을 해야 합니다.

  • PostgreSQL에 데이터를 생성한 다음 Elasticsearch로 인덱싱합니다.
  • Elasticsearch의 애플리케이션 설정을 활성화합니다(기본적으로 비활성화되어 있음).

이를 위해 다음을 사용합니다.

before do
  stub_ee_application_setting(elasticsearch_search: true, elasticsearch_indexing: true)
end

또한 ensure_elasticsearch_index! 메서드를 사용하면 Elasticsearch의 비동기적 특성을 극복할 수 있습니다. 이 메서드는 Elasticsearch Refresh API를 사용해 마지막 새로 고침 이후 인덱스에서 수행된 모든 작업을 검색할 수 있게 합니다. 이 메서드는 보통 PostgreSQL에 데이터를 로드한 후 호출해 데이터가 인덱싱되고 검색 가능한지 확인합니다.

Elasticsearch 메타데이터를 사용하면 ElasticsearchHelpers의 헬퍼 메서드가 자동으로 포함됩니다. :elastic_helpers 메타데이터로 직접 포함할 수도 있습니다.

SEARCH_SPEC_BENCHMARK 환경 변수를 사용하면 테스트 설정 단계를 벤치마크할 수 있습니다.

SEARCH_SPEC_BENCHMARK=1 bundle exec rspec ee/spec/lib/elastic/latest/merge_request_class_proxy_spec.rb

레거시 Snowplow 이벤트 테스트#

이 섹션에서는 아직 내부 이벤트로 전환되지 않은 이벤트를 테스트하는 방법을 설명합니다.

백엔드#
Warning

Snowplow는 contracts gem을 사용해 런타임 타입 검사를 수행합니다. Snowplow는 테스트와 개발에서 기본적으로 비활성화되어 있으므로 Gitlab::Tracking을 모킹할 때 예외를 포착하기 어려울 수 있습니다.

타입 검사로 인한 런타임 오류를 포착하려면 Gitlab::Tracking#event 호출을 확인하는 expect_snowplow_event를 사용할 수 있습니다.

describe '#show' do
  it 'tracks snowplow events' do
    get :show

    expect_snowplow_event(
      category: 'Experiment',
      action: 'start',
      namespace: group,
      project: project
    )
    expect_snowplow_event(
      category: 'Experiment',
      action: 'sent',
      property: 'property',
      label: 'label',
      namespace: group,
      project: project
    )
  end
end

이벤트가 호출되지 않았음을 확인하려면 expect_no_snowplow_event를 사용할 수 있습니다.

  describe '#show' do
    it 'does not track any snowplow events' do
      get :show

      expect_no_snowplow_event(category: described_class.name, action: 'some_action')
    end
  end

category와 action은 생략할 수 있지만, 불안정한 테스트를 피하려면 최소한 category는 지정해야 합니다. 예를 들어 Users::ActivityService는 API 요청 후에 Snowplow 이벤트를 추적할 수 있으며, 인수를 지정하지 않은 상태에서 이것이 실행되면 expect_no_snowplow_event가 실패합니다.

데이터 속성이 있는 뷰 계층#
Note

아래에 나오는 data-track-* 속성과 have_tracking 매처는 레거시 Snowplow 추적 시스템의 일부입니다. 새 추적에는 data-event-tracking 속성을 대신 사용합니다. 자세한 내용은 마이그레이션 가이드를 참고합니다.

Haml 계층에서 데이터 속성을 사용해 추적을 등록한다면, have_tracking 매처 메서드를 사용해 기대하는 데이터 속성이 할당되었는지 단언할 수 있습니다.

예를 들어 아래 Haml을 테스트해야 한다면

%div{ data: { testid: '_testid_', track_action: 'render', track_label: '_tracking_label_' } }
    it 'assigns the tracking items' do
      render

      expect(rendered).to have_tracking(action: 'render', label: '_tracking_label_', testid: '_testid_')
    end
  it 'assigns the tracking items' do
    render_inline(component)

    expect(page).to have_tracking(action: 'render', label: '_tracking_label_', testid: '_testid_')
  end

추적이 할당되지 않았음을 확인하려면 위 매처와 함께 not_to를 사용할 수 있습니다.

위의 어떤 매처도 실제로 송출된 이벤트를 관찰하지 않습니다. expect_snowplow_event는 Gitlab::Tracking의 목에 대해 단언하고, have_tracking은 렌더링된 마크업에 대해 단언합니다. 백엔드와 프론트엔드 모두에서 GitLab이 송출하는 페이로드를 단언하려면 기능 스펙에서 Snowplow 이벤트 캡처하기를 참고합니다.

스키마에 대한 Snowplow 컨텍스트 테스트#

Snowplow 스키마 매처는 JSON 스키마에 대해 Snowplow 컨텍스트를 테스트해 검증 오류를 줄이는 데 도움이 됩니다. 스키마 매처는 다음 매개변수를 받습니다.

  • schema path
  • context

스키마 매처 스펙을 추가하려면 다음과 같이 합니다.

  1. Iglu 리포지터리에 새 스키마를 추가한 다음, 같은 스키마를 spec/fixtures/product_intelligence/ 디렉터리에 복사합니다.

  2. 복사한 스키마에서 "$schema" 키와 값을 제거합니다. 스펙에는 필요하지 않으며, 키를 유지하면 URL에서 스키마를 찾으려고 시도해 스펙이 실패합니다.

  3. 다음 스니펫을 사용해 스키마 매처를 호출합니다.

    match_snowplow_context_schema(schema_path: '<filename from step 1>', context: <Context Hash> )
    

Prometheus 테스트#

Prometheus 지표는 테스트 실행 간에 유지될 수 있습니다. 각 예제 전에 지표가 초기화되도록 하려면 RSpec 테스트에 :prometheus 태그를 추가합니다.

매처#

RSpec 기대 조건의 의도를 명확하게 하거나 복잡성을 숨기기 위해 커스텀 매처를 만들어야 합니다. 커스텀 매처는 spec/support/matchers/ 아래에 두어야 합니다. 매처가 특정 유형의 스펙(예: 기능 또는 요청)에만 적용된다면 하위 폴더에 둘 수 있지만, 여러 유형의 스펙에 적용된다면 하위 폴더에 두지 않아야 합니다.

be_like_time#

데이터베이스에서 반환된 시간은 Ruby의 시간 객체와 정밀도가 다를 수 있으므로, 스펙에서 비교할 때는 유연한 허용 오차가 필요합니다.

PostgreSQL의 time 및 timestamp 타입은 1마이크로초의 해상도를 가집니다. 그러나 Ruby Time의 정밀도는 OS에 따라 다를 수 있습니다.

다음 스니펫을 살펴봅니다.

project = create(:project)

expect(project.created_at).to eq(Project.find(project.id).created_at)

Linux에서 Time은 최대 9의 정밀도를 가질 수 있으며 project.created_at은 같은 정밀도의 값(예: 2023-04-28 05:53:30.808033064)을 가집니다. 그러나 데이터베이스에 저장되고 로드된 실제 created_at 값(예: 2023-04-28 05:53:30.808033)은 같은 정밀도를 갖지 않으므로 일치 검사가 실패합니다. macOS X에서는 Time의 정밀도가 PostgreSQL timestamp 타입과 일치하므로 일치 검사가 성공할 수 있습니다.

이 문제를 피하려면 be_like_time 또는 be_within을 사용해 시간이 서로 1초 이내인지 비교할 수 있습니다.

예시:

expect(metrics.merged_at).to be_like_time(time)

be_within 예시:

expect(violation.reload.merged_at).to be_within(0.00001.seconds).of(merge_request.merged_at)

have_gitlab_http_status#

have_http_status와 expect(response.status).to보다 have_gitlab_http_status를 사용합니다. 전자는 상태가 일치하지 않을 때마다 응답 본문도 표시할 수 있기 때문입니다. 일부 테스트가 깨지기 시작했을 때 소스를 수정하고 테스트를 다시 실행하지 않고도 이유를 알고 싶을 때 매우 유용합니다.

특히 500 내부 서버 오류가 표시될 때 유용합니다.

206 같은 숫자 표현보다 :no_content 같은 이름이 있는 HTTP 상태를 사용합니다. 지원되는 상태 코드 목록을 참고합니다.

예시:

expect(response).to have_gitlab_http_status(:ok)

이 매처는 기능 스펙에서 Capybara::Session도 받으며 해당 세션의 status_code를 확인합니다.

expect(page).to have_gitlab_http_status(:not_found)

match_schema와 match_response_schema#

match_schema 매처를 사용하면 대상이 JSON 스키마와 일치하는지 검증할 수 있습니다. expect 안의 항목은 JSON 문자열 또는 JSON과 호환되는 데이터 구조일 수 있습니다.

match_response_schema는 요청 스펙의 응답 객체와 함께 사용하는 편의 매처입니다.

예시:

# Matches against spec/fixtures/api/schemas/prometheus/additional_metrics_query_result.json
expect(data).to match_schema('prometheus/additional_metrics_query_result')

# Matches against ee/spec/fixtures/api/schemas/board.json
expect(data).to match_schema('board', dir: 'ee')

# Matches against a schema made up of Ruby data structures
expect(data).to match_schema(Atlassian::Schemata.build_info)

be_valid_json#

be_valid_json을 사용하면 문자열이 JSON으로 파싱되고 비어 있지 않은 결과를 내는지 검증할 수 있습니다. 위의 스키마 매칭과 결합하려면 and를 사용합니다.

expect(json_string).to be_valid_json

expect(json_string).to be_valid_json.and match_schema(schema)

be_one_of(collection)#

include의 반대로, collection에 기대하는 값이 포함되어 있는지 테스트합니다.

expect(:a).to be_one_of(%i[a b c])
expect(:z).not_to be_one_of(%i[a b c])

have_no_testid#

have_testid의 반대입니다.

expect(page).to have_no_testid('relationship-blocks-icon')

쿼리 성능 테스트#

쿼리 성능을 테스트하면 다음을 할 수 있습니다.

  • 코드 블록에 N+1 문제가 없음을 단언합니다.
  • 코드 블록의 쿼리 수가 눈에 띄지 않게 증가하지 않도록 합니다.

기능(:js) 스펙에서는 N+1이나 쿼리 수를 단언하지 않습니다. 브라우저 컨텍스트에서는 쿼리 수 기준선이 불안정합니다. QueryRecorder와 exceed_query_limit 단언은 요청 스펙이나 컨트롤러 스펙에 둡니다. 첫 번째 호출이 캐시를 지연 로드한다면 측정하기 전에 워밍업 요청으로 기록되는 기준선을 미리 채웁니다.

QueryRecorder#

QueryRecorder를 사용하면 주어진 코드 블록에서 수행되는 데이터베이스 쿼리 수를 프로파일링하고 테스트할 수 있습니다.

자세한 내용은 QueryRecorder 섹션을 참고합니다.

GitalyClient#

Gitlab::GitalyClient.get_request_count를 사용하면 주어진 코드 블록에서 수행하는 Gitaly 쿼리 수를 테스트할 수 있습니다.

자세한 내용은 Gitaly Request Counts 섹션을 참고합니다.

공유 컨텍스트와 공유 예제#

하나의 스펙 파일에서만 사용하는 공유 컨텍스트나 공유 예제는 인라인으로 선언할 수 있습니다.

둘 이상의 스펙 파일에서 사용하는 공유 예제는 범위에 따라 위치가 달라집니다.

단일 바운디드 컨텍스트의 공유 예제:

  • 바운디드 컨텍스트의 디렉터리 구조에 둘 수 있습니다(예: ee/spec/requests/api/graphql/remote_development/shared_examples.rb).
  • 이 방식은 바운디드 컨텍스트의 응집도를 유지하며 모듈러 모놀리스 아키텍처와 일치합니다.

여러 바운디드 컨텍스트에서 사용하는 공유 예제:

  • spec/support/shared_* 아래에 두어야 합니다.
  • 특정 유형의 스펙(예: 기능 또는 요청)에만 적용된다면 하위 폴더에 둘 수 있지만, 여러 유형의 스펙에 적용된다면 하위 폴더에 두지 않아야 합니다.

일반 지침:

  • 공유 예제가 특정 바운디드 컨텍스트에서만 사용된다면 그 컨텍스트에 두는 것을 우선합니다.
  • 공유 예제가 서로 다른 바운디드 컨텍스트에서 실제로 공유될 때만 전역 spec/support/shared_* 디렉터리로 옮깁니다.
  • 공유 예제와 공유 컨텍스트 파일은 보통 *_contexts.rb, *_examples.rb, *_shared.rb, *_shared_context_and_examples.rb 같은 이름 패턴을 사용합니다.
  • 목표는 바운디드 컨텍스트의 높은 응집도를 유지하면서 컨텍스트 사이의 결합을 느슨하게 유지하는 것입니다.

느린 공유 예제의 성능 영향#

느린 공유 예제는 그 비용이 배가됩니다. 30초가 걸리는 예제 하나가 스펙 파일 10개에 포함되면 CI 시간이 30초가 아니라 300초가 듭니다.

코드베이스 전반에 폭넓게 포함되는 spec/support/shared_* 아래의 공유 예제는 단일 스펙 예제보다 더 엄격한 성능 기준이 적용됩니다.

지침:

  • 느린 예제를 공유 컨텍스트로 추출하기 전에 로컬 스펙으로 프로파일링하고 최적화합니다.
  • 계약이 데이터베이스 상태를 명시적으로 요구하지 않는 한 공유 예제에서 create 호출을 피합니다. build_stubbed 또는 build를 사용합니다.
  • 공유 예제에 :js가 필요하다면 UI를 단언하는 부분을 분리해 나머지를 일반 요청 스펙으로 실행할 수 있는지 고려합니다.
  • 폭넓게 포함되는 느린 공유 예제를 팩토리 연쇄처럼 다룹니다. 한 곳의 작은 수정이 스위트 전체에서 큰 누적 절감으로 이어집니다. 팩토리 사용 최적화를 참고합니다.

CE/EE 공유 예제 규칙#

스펙 파일에 EE 미러(ee/spec/ 아래의 대응 파일)가 있다면, 핵심 동작을 다루는 공유 예제를 중복해서 작성하지 말고 재사용합니다. 해당 공유 예제를 spec/support/shared_examples/에 정의한 다음 다음과 같이 합니다.

  • CE 스펙은 CE에 존재하는 케이스에 it_behaves_like를 사용합니다.
  • EE 미러는 EE 전용 케이스에 it_behaves_like를 사용하며, 라이선스로 제한되는 케이스는 stub_licensed_features로 감쌉니다.

spec/spec_helper.rb가 spec/support/ 아래의 모든 파일을 require하고, Gitlab.ee?일 때 ee/spec/spec_helper.rb도 require하므로 spec/support/shared_examples/가 적합한 위치입니다. 따라서 CE와 EE 실행 모두 CE 지원 트리를 로드하고, EE 실행은 추가로 ee/spec/support/를 로드합니다.

한 스펙 파일에서 공유 예제를 정의하고 다른 파일에서 사용하지 않습니다. RSpec은 현재 실행에 포함된 스펙 파일만 로드하며 스펙 파일은 다른 스펙 파일을 require하지 않으므로, 정의한 파일이 실행에 포함되지 않으면 공유 예제를 찾을 수 없습니다. bundle exec rspec ee/spec/requests/api/notes_spec.rb만 단독으로 실행하면 Could not find shared examples로 실패합니다. 예제 그룹 안의 shared_examples 호출도 해당 그룹으로 범위가 제한되므로, 두 파일이 모두 로드되어도 다른 파일의 describe에서는 보이지 않습니다.

예를 들어 spec/support/shared_examples/requests/api/notes_shared_examples.rb는 핵심 엔드포인트 동작을 정의합니다.

RSpec.shared_examples 'noteable API' do |parent_type, noteable_type, id_name|
  # core endpoint behavior
end

spec/requests/api/notes_spec.rb는 CE에서 사용할 수 있는 noteable에 이를 사용합니다.

it_behaves_like 'noteable API', 'projects', 'wiki_pages', 'id' do
  let(:parent) { project }
  let(:noteable) { wiki_page_meta }
  let(:note) { wiki_page_meta_note }
end

그리고 ee/spec/requests/api/notes_spec.rb는 EE 전용 noteable에 같은 공유 예제를 사용합니다.

context 'when noteable is a WikiPage::Meta for a group wiki' do
  before do
    stub_licensed_features(group_wikis: true)
  end

  it_behaves_like 'noteable API', 'groups', 'wiki_pages', 'id' do
    let(:parent) { group }
    let(:noteable) { wiki_page_meta }
    let(:note) { wiki_page_meta_note }
  end
end

기존 공유 예제를 spec/support/shared_examples/로 옮길 때는 다른 최상위 RSpec.shared_examples가 이미 같은 이름을 사용하지 않는지 확인합니다. RSpec은 이름이 중복되어도 실패하지 않습니다. 경고를 출력하고 나중에 정의한 것이 조용히 이전 정의를 덮어씁니다. 이는 현재 CE 스펙과 그 EE 미러가 모두 정의하는 이름에 가장 중요합니다. 이러한 정의는 그룹 범위로 제한되므로 작성된 그대로는 충돌하지 않기 때문입니다. 어느 쪽이든 지원 파일로 승격하기 전에 한쪽의 이름을 변경합니다.

헬퍼#

헬퍼는 대개 특정 RSpec 예제의 복잡성을 숨기는 메서드를 제공하는 모듈입니다. 다른 스펙과 공유할 의도가 없다면 RSpec 파일 안에 헬퍼를 정의할 수 있습니다. 그렇지 않으면 spec/support/helpers/ 아래에 두어야 합니다. 헬퍼가 특정 유형의 스펙(예: 기능 또는 요청)에만 적용된다면 하위 폴더에 둘 수 있지만, 여러 유형의 스펙에 적용된다면 하위 폴더에 두지 않아야 합니다.

헬퍼는 spec/support/helpers/를 루트로 하는 Rails 명명 / 네임스페이스 규칙을 따라야 합니다. 예를 들어 spec/support/helpers/features/iteration_helpers.rb는 다음과 같이 정의해야 합니다.

# frozen_string_literal: true

module Features
  module IterationHelpers
    def iteration_period(iteration)
      "#{iteration.start_date.to_fs(:medium)} - #{iteration.due_date.to_fs(:medium)}"
    end
  end
end

헬퍼는 RSpec 구성을 변경해서는 안 됩니다. 예를 들어 위에서 설명한 헬퍼 모듈에는 다음을 포함하지 않아야 합니다.

# bad
RSpec.configure do |config|
  config.include Features::IterationHelpers
end

# good, include in specific spec
RSpec.describe 'Issue Sidebar', feature_category: :team_planning do
  include Features::IterationHelpers
end

Ruby 상수 테스트#

Ruby 상수를 사용하는 코드를 테스트할 때는 상수의 값을 테스트하기보다 상수에 의존하는 동작에 테스트의 초점을 맞춥니다.

예를 들어 다음은 클래스 메서드 .categories의 동작을 테스트하므로 권장됩니다.

  describe '.categories' do
    it 'gets CE unique category names' do
      expect(described_class.categories).to include(
        'deploy_token_packages',
        'user_packages',
        # ...
        'kubernetes_agent'
      )
    end
  end

반면 상수 자체의 값을 테스트하면 코드와 테스트에서 같은 값을 반복할 뿐인 경우가 많아 가치가 거의 없습니다.

  describe CATEGORIES do
  it 'has values' do
    expect(CATEGORIES).to eq([
                            'deploy_token_packages',
                            'user_packages',
                            # ...
                            'kubernetes_agent'
                             ])
  end
end

상수의 오류가 치명적인 영향을 줄 수 있는 중요한 경우에는 상수 값을 테스트하는 것이 추가 안전장치로 유용할 수 있습니다. 예를 들어 GitLab 서비스 전체를 중단시키거나, 고객에게 마땅히 청구해야 할 금액보다 많이 청구하게 하거나, 우주가 내파하게 만들 수 있는 경우입니다.

팩토리#

GitLab은 테스트 픽스처 대체제로 factory_bot을 사용합니다.

  • 팩토리 정의는 spec/factories/에 두며, 해당 모델 이름의 복수형을 사용해 이름을 짓습니다(User 팩토리는 users.rb에 정의).

  • 파일당 최상위 팩토리 정의는 하나만 있어야 합니다.

  • 특히 커스텀 로직을 사용하는 경우 팩토리에 대한 스펙을 만드는 것을 고려합니다. 예를 들어 after(:build) 훅의 로직입니다. 팩토리 스펙은 spec/factories_specs에 저장됩니다.

  • FactoryBot 메서드는 모든 RSpec 그룹에 믹스인됩니다. 즉 FactoryBot.create(...) 대신 create(...)를 호출할 수 있고 호출해야 합니다.

  • 정의와 사용을 깔끔하게 하려면 트레이트를 활용합니다.

  • 팩토리를 정의할 때 결과 레코드가 유효성 검사를 통과하는 데 필요하지 않은 속성은 정의하지 않습니다.

  • 팩토리에서 인스턴스를 만들 때 테스트에 필요하지 않은 속성은 전달하지 않습니다.

  • 콜백에서 연관 관계를 설정할 때는 create / build 대신 암묵적, 명시적 또는 인라인 연관 관계를 사용합니다. 자세한 배경은 이슈 #262624를 참고합니다.

    has_many와 belongs_to 연관 관계가 있는 팩토리를 만들 때는 instance 메서드를 사용해 빌드 중인 객체를 참조합니다. 이렇게 하면 상호 연결된 연관 관계를 사용해 불필요한 레코드 생성을 방지합니다.

    예를 들어 다음 클래스가 있다면

    class Car < ApplicationRecord
      has_many :wheels, inverse_of: :car, foreign_key: :car_id
    end
    
    class Wheel < ApplicationRecord
      belongs_to :car, foreign_key: :car_id, inverse_of: :wheel, optional: false
    end
    

    다음 팩토리를 만들 수 있습니다.

    FactoryBot.define do
      factory :car do
        transient do
          wheels_count { 2 }
        end
    
        wheels do
          Array.new(wheels_count) do
            association(:wheel, car: instance)
          end
        end
      end
    end
    
    FactoryBot.define do
      factory :wheel do
        car { association :car }
      end
    end
    
  • 팩토리는 ActiveRecord 객체로 제한되지 않습니다. 예시를 참고합니다.

  • 팩토리에서 skip_callback 사용을 피합니다. 자세한 내용은 이슈 #247865를 참고합니다.

픽스처#

모든 픽스처는 spec/fixtures/ 아래에 두어야 합니다.

리포지터리#

머지 리퀘스트 병합처럼 일부 기능을 테스트하려면 특정 상태의 Git 리포지터리가 테스트 환경에 있어야 합니다.

리포지터리는 프로젝트 팩토리가 만들 수 있는 것 중 비용이 가장 큰 것 중 하나이므로, 테스트가 실제로 필요로 하는 것과 일치하는 트레이트를 선택합니다.

트레이트 테스트에 다음이 필요한 경우 사용
리포지터리 트레이트 없음 Git 접근이 전혀 필요하지 않음
:small_repo 커밋이 하나이고 유효한 기본 브랜치가 있는 리포지터리
:custom_repo 특정 파일 경로 또는 내용
:repository 여러 브랜치, 태그, 머지 충돌 같은 gitlab-test 히스토리

이 중 무엇이든 선택하기 전에 테스트가 Git을 전혀 사용하는지 확인합니다. 데이터베이스 레코드, 권한, 직렬화만 실행하는 스펙에는 리포지터리가 필요하지 않습니다. 트레이트를 제거하면 가장 큰 단일 절감 효과를 얻을 수 있습니다.

트레이트는 대략 비용 순서로 나열되어 있지만, :custom_repo와 :small_repo는 파일마다 커밋을 하나씩 만들므로 파일이 많은 :custom_repo는 히스토리를 한 번에 로드하는 :repository보다 비용이 더 클 수 있습니다. 표에서의 위치만으로 고르지 말고 테스트가 필요로 하는 것을 기준으로 선택합니다.

테스트 대상 코드가 존재하지만 커밋이 없는 리포지터리와 리포지터리가 없는 프로젝트를 구별해야 할 때는 :empty_repo를 사용합니다. 이 트레이트는 위 트레이트보다 저렴한 대안이 아니라 동작상의 요구 사항을 나타내므로, 테스트가 그 상태에 의존할 때만 선택합니다.

:small_repo#

:small_repo 트레이트는 test.txt 파일 하나가 있는 리포지터리를 만들고 기본 브랜치를 HEAD로 설정합니다. 테스트에 커밋을 확인할 수 있는 리포지터리가 필요하지만 그 안에 무엇이 있는지는 중요하지 않을 때 사용합니다.

let_it_be(:project) { create(:project, :small_repo) }

project.commit을 호출하거나, ref에 대해 파이프라인을 만들거나, 그 밖에 리포지터리가 비어 있지 않아야 하는 스펙에 이 트레이트를 사용합니다.

:custom_repo#

:custom_repo 트레이트는 프로젝트 리포지터리의 기본 브랜치에 어떤 파일이 나타나는지 정확히 지정합니다. 각 파일은 별도의 커밋으로 생성됩니다.

let_it_be(:project) do
  create(
    :project, :custom_repo,
    files: {
      'README.md'       => 'Content here',
      'foo/bar/baz.txt' => 'More content here'
    }
  )
end

그러면 기본 권한과 지정한 내용을 가진 두 개의 파일이 있는 리포지터리가 만들어집니다.

:repository#

GitLab은 현실적인 히스토리가 필요한 경우를 위해 gitlab-test 리포지터리를 유지 관리합니다. :repository 트레이트를 사용하면 그 사본을 가져올 수 있습니다.

let_it_be(:project) { create(:project, :repository) }

이 트레이트는 테스트 환경이 준비한 번들에서 리포지터리를 로드하므로, 프로젝트가 gitlab-test 히스토리 전체를 한 번에 가져옵니다. 브랜치, 태그, 서브모듈, 머지 충돌처럼 그 히스토리에 의존하는 테스트에만 사용합니다. 테스트에 커밋이 존재하기만 하면 된다면 대신 :small_repo를 사용합니다.

구성#

RSpec 구성 파일은 RSpec 구성을 변경하는 파일입니다(예: RSpec.configure do |config| 블록). 이 파일은 spec/support/ 아래에 두어야 합니다.

각 파일은 spec/support/capybara.rb 또는 spec/support/carrierwave.rb처럼 특정 도메인과 관련되어야 합니다.

헬퍼 모듈이 특정 종류의 스펙에만 적용된다면 config.include 호출에 수정자를 추가해야 합니다. 예를 들어 spec/support/helpers/cycle_analytics_helpers.rb가 :lib 및 type: :model 스펙에만 적용된다면 다음과 같이 작성합니다.

RSpec.configure do |config|
  config.include Spec::Support::Helpers::CycleAnalyticsHelpers, :lib
  config.include Spec::Support::Helpers::CycleAnalyticsHelpers, type: :model
end

구성 파일이 config.include로만 이루어져 있다면 이러한 config.include를 spec/spec_helper.rb에 직접 추가할 수 있습니다.

매우 일반적인 헬퍼는 spec/fast_spec_helper.rb 파일이 사용하는 spec/support/rspec.rb 파일에 포함하는 것을 고려합니다. spec/fast_spec_helper.rb 파일에 관한 자세한 내용은 빠른 단위 테스트를 참고합니다.

테스트 환경 로깅#

Gitaly, Workhorse, Elasticsearch, Capybara를 포함해 테스트 환경의 서비스는 테스트를 실행할 때 자동으로 구성되고 시작됩니다. CI에서 실행하거나 서비스를 설치해야 하는 경우 테스트 환경은 설정 시간에 관한 정보를 기록하며, 다음과 같은 로그 메시지를 생성합니다.

==> Setting up Gitaly...
    Gitaly set up in 31.459649 seconds...

==> Setting up GitLab Workhorse...
    GitLab Workhorse set up in 29.695619 seconds...
fatal: update refs/heads/diff-files-symlink-to-image: invalid <newvalue>: 8cfca84
From https://gitlab.com/gitlab-org/gitlab-test
 * [new branch]      diff-files-image-to-symlink -> origin/diff-files-image-to-symlink
 * [new branch]      diff-files-symlink-to-image -> origin/diff-files-symlink-to-image
 * [new branch]      diff-files-symlink-to-text -> origin/diff-files-symlink-to-text
 * [new branch]      diff-files-text-to-symlink -> origin/diff-files-text-to-symlink
   b80faa8..40232f7  snippet/multiple-files -> origin/snippet/multiple-files
 * [new branch]      testing/branch-with-#-hash -> origin/testing/branch-with-#-hash

==> Setting up GitLab Elasticsearch Indexer...
    GitLab Elasticsearch Indexer set up in 26.514623 seconds...

이 정보는 로컬에서 실행할 때와 수행할 작업이 없을 때는 생략됩니다. 이 메시지를 항상 보려면 다음 환경 변수를 설정합니다.

GITLAB_TESTING_LOG_LEVEL=debug

테스트 문서로 돌아가기