테스트 모범 사례
GitLab v19.1GitLab에서 테스트는 나중에 추가하는 것이 아니라 최우선으로 고려하는 요소입니다. 기능을 구현할 때는 올바른 방법으로 올바른 기능을 개발하는 것을 고려합니다. 테스트 휴리스틱(heuristics)은 이 문제를 해결하는 데 도움이 됩니다.
테스트 모범 사례#
테스트 설계#
GitLab에서 테스트는 나중에 추가하는 것이 아니라 최우선으로 고려하는 요소입니다. 기능 설계를 고민하듯 테스트 설계도 신중하게 접근하는 것이 중요합니다.
기능을 구현할 때는 올바른 방법으로 올바른 기능을 개발하는 것을 고려합니다. 이를 통해 범위를 관리 가능한 수준으로 좁힐 수 있습니다. 기능에 대한 테스트를 구현할 때는 올바른 테스트를 개발하면서 동시에 테스트가 실패할 수 있는 모든 중요한 경우를 다뤄야 합니다. 이로 인해 범위가 관리하기 어려운 수준으로 급격히 넓어질 수 있습니다.
테스트 휴리스틱(heuristics)은 이 문제를 해결하는 데 도움이 됩니다. 이는 코드에서 버그가 나타나는 일반적인 방식들을 간결하게 다룹니다. 테스트를 설계할 때는 잘 알려진 테스트 휴리스틱을 검토하여 테스트 설계에 참고하세요. Handbook의 Testing Guide에서 유용한 휴리스틱을 찾을 수 있습니다.
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/AvoidConditionalStatementsRuboCop Cop - MR). -
테스트 순서를 클래스 내 순서와 일치시키도록 노력하세요.
-
가능한 경우 테이블 기반 테스트를 사용하세요.
-
단계를 구분하기 위해 빈 줄을 사용하여 Four-Phase Test 패턴을 따르도록 노력하세요.
-
'localhost'를 하드코딩하지 말고Gitlab.config.gitlab.host를 사용하세요. -
테스트의 리터럴 URL에는
example.com,gitlab.example.com을 사용하세요. 이렇게 하면 실제 URL을 사용하지 않을 수 있습니다. -
시퀀스 생성 속성의 절대값에 대해 단언(assert)하지 마세요( Gotchas 참고).
-
expect_any_instance_of또는allow_any_instance_of사용을 피하세요( Gotchas 참고). -
:each가 기본값이므로 훅에:each인수를 제공하지 마세요. -
before및after훅에서는:all보다:context로 범위를 지정하는 것을 선호하세요. -
특정 요소에 작동하는
evaluate_script("$('.js-foo').testSomething()")(또는execute_script)를 사용할 때는, 요소가 실제로 존재하는지 확인하기 위해 먼저 Capybara 매처(예:find('.js-foo'))를 사용하세요. -
실행하려는 spec의 일부를 격리하려면
focus: true를 사용하세요. -
테스트에 둘 이상의 기댓값이 있는 경우
:aggregate_failures를 사용하세요. -
빈 테스트 설명 블록의 경우, 테스트가 자명하다면
it do대신specify를 사용하세요. -
실제로 존재하지 않는 ID/IID/접근 수준이 필요한 경우
non_existing_record_id/non_existing_record_iid/non_existing_record_access_level을 사용하세요. 123, 1234, 999 등의 값을 사용하면 CI 실행 컨텍스트에서 데이터베이스에 실제로 해당 ID가 존재할 수 있으므로 불안정합니다. -
새로운 테스트를 작성할 때는, 통과를 단언하기 전에 예상하는 방식으로 실패하는지 확인하세요. 조건을 반전하거나 테스트 대상 동작을 제거한 상태로 스펙을 실행하여 실패 메시지가 의미 있는지 확인하세요. 실패할 수 없는 테스트는 커버리지를 제공하지 않습니다.
애플리케이션 코드 이거 로딩(Eager Loading)#
기본적으로 테스트 런타임 속도를 높이기 위해 테스트 환경에서는 애플리케이션 코드를 이거 로딩하지 않습니다.
테스트 실행 시 이거 로딩을 활성화해야 한다면,
GITLAB_TEST_EAGER_LOAD 환경 변수를 사용하세요:
GITLAB_TEST_EAGER_LOAD=1 bin/rspec spec/models/project_spec.rb
테스트가 모든 애플리케이션 코드가 로드된 상태에 의존하는 경우, :eager_load 태그를 추가하세요.
이렇게 하면 테스트 실행 전에 애플리케이션 코드가 이거 로드됩니다.
Ruby 경고#
스펙 실행 시 기본적으로 지원 중단 경고(deprecation warnings)를 활성화했습니다. 이러한 경고를 개발자에게 더 잘 보이도록 하면 새로운 Ruby 버전으로 업그레이드하는 데 도움이 됩니다.
예를 들어, 환경 변수 SILENCE_DEPRECATIONS를 설정하여 지원 중단 경고를 무시할 수 있습니다:
# silence all deprecation warnings
SILENCE_DEPRECATIONS=1 bin/rspec spec/models/project_spec.rb
테스트 순서#
History
불안정한 테스트(flaky tests) 중 테스트 순서에 의존하는 것들을 발견하기 위해, 모든 새로운 스펙 파일은 무작위 순서로 실행됩니다.
무작위 실행 시:
-
예시 그룹 설명 아래에
# order random문자열이 추가됩니다. -
사용된 시드(seed)는 테스트 스위트 요약 아래의 스펙 출력에 표시됩니다. 예:
Randomized with seed 27443.
정의된 순서로 여전히 실행되는 스펙 파일 목록은 rspec_order_todo.yml을 참조하세요.
스펙 파일을 무작위 순서로 실행하려면, 다음 명령으로 순서 의존성을 확인하세요:
scripts/rspec_check_order_dependence spec/models/project_spec.rb
스펙이 검사를 통과하면 스크립트가
rspec_order_todo.yml에서 해당 파일을 자동으로 제거합니다.
스펙이 검사를 통과하지 못하면 무작위 순서로 실행하기 전에 수정해야 합니다.
불안정한 테스트(Test Flakiness)#
불안정한 테스트를 방지하기 위해 마련된 프로세스에 대한 자세한 내용은 비정상 테스트 페이지를 참조하세요.
느린 테스트(Test Slowness)#
GitLab에는 방대한 테스트 스위트가 있으며, 병렬화 없이는 실행하는 데 몇 시간이 걸릴 수 있습니다. 정확하고 효과적인 테스트를 작성하는 것은 물론 빠른 테스트를 작성하기 위해 노력하는 것이 중요합니다.
테스트 성능은 품질과 개발 속도를 유지하는 데 중요하며, CI 빌드 시간과 그에 따른 고정 비용에 직접적인 영향을 미칩니다. 철저하고 정확하며 빠른 테스트를 원합니다. 여기서는 이를 달성하기 위해 사용할 수 있는 도구와 기법에 대한 정보를 찾을 수 있습니다.
느린 테스트를 방지하기 위해 마련된 프로세스에 대한 자세한 내용은 비정상 테스트 페이지를 참조하세요.
필요하지 않은 기능은 요청하지 않기#
예시나 상위 컨텍스트에 어노테이션을 달아 예시에 기능을 추가하기 쉽게 만들어 두었습니다. 그 예시는 다음과 같습니다:
-
feature 스펙에서
:js는 완전한 JavaScript 지원 헤드리스 브라우저를 실행합니다. -
:clean_gitlab_redis_cache는 예시에 깨끗한 Redis 캐시를 제공합니다. -
:request_store는 예시에 요청 저장소(request store)를 제공합니다.
테스트 의존성을 줄여야 하며, 기능 사용을 피하면 필요한 설정 양도 줄어듭니다.
:js는 특히 피하는 것이 중요합니다. 기능 테스트에서 브라우저의 JavaScript 반응성이 필요한 경우에만(예: Vue.js 컴포넌트 클릭) 사용해야 합니다.
헤드리스 브라우저를 사용하는 것은 애플리케이션의 HTML 응답을 파싱하는 것보다 훨씬 느립니다.
프로파일링: 테스트가 어디에 시간을 쓰는지 확인하기#
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뷰는 함수 호출이 많을 때(예: feature 스펙) 특히 유용합니다. -
확대하거나 축소할 수 있습니다! 탐색 문서를 참고하세요.
-
느린 feature 테스트를 작업 중이라면, 검색창에
Capybara::DSL#을 검색하여 Capybara 액션이 어떻게 수행되는지, 각각 얼마나 시간이 걸리는지 확인해 보세요!
분석 예시는 #414929 또는 #375004를 참고하세요.
팩토리 사용 최적화#
느린 테스트의 일반적인 원인 중 하나는 객체를 지나치게 많이 생성하는 것이며, 그로 인해 연산과 데이터베이스(DB) 처리 시간이 늘어납니다. 팩토리는 개발에 필수적이지만, DB에 데이터를 삽입하는 것을 너무 쉽게 만들어 최적화의 여지를 놓칠 수 있습니다.
여기서 기억해야 할 두 가지 기본 기법은 다음과 같습니다:
-
줄이기(Reduce): 객체 생성을 피하고, 영속화(persist)도 피한다.
-
재사용(Reuse): 공유 객체, 특히 직접 검사하지 않는 중첩 객체는 일반적으로 공유할 수 있다.
객체 생성을 피하기 위해 다음 사항을 염두에 두세요:
-
instance_double과spy는FactoryBot.build(...)보다 빠릅니다. -
FactoryBot.build(...)와.build_stubbed는.create보다 빠릅니다. -
build,build_stubbed,attributes_for,spy,instance_double을 사용할 수 있을 때는 객체를create하지 마세요. DB 영속화는 느립니다!
Factory Doctor를 사용하면 특정 테스트에서 DB 영속화가 필요하지 않은 케이스를 찾을 수 있습니다.
# 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는 팩토리를 통한 반복적인 DB 영속화를 식별하는 데 도움이 됩니다.
# 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로 생성된 객체는
동결(frozen)됩니다.
208개의 서로 다른 프로젝트를 생성할 필요가 없을 수도 있습니다. 하나를 생성하고 재사용할 수 있습니다. 또한, 생성된 프로젝트 중 우리가 명시적으로 요청한 것은 약 1/3에 불과합니다(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은 객체를 지연(lazy) 생성합니다. 객체가 호출되기 전까지는 생성되지 않습니다.let은 모든 예제마다 새 객체를 생성하기 때문에 일반적으로 비효율적입니다. 단순한 값에는let을 사용해도 괜찮습니다. 그러나 팩토리와 같은 데이터베이스 모델을 다룰 때는 더 효율적인let변형을 사용하는 것이 좋습니다. -
let_it_be_with_refind는let_it_be_with_reload와 유사하게 동작하지만, 전자는ActiveRecord::Base#reload대신ActiveRecord::Base#find를 호출합니다.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를 참조하세요.
Pro-tip: 테스트를 작성할 때, let_it_be 내의 객체는 **불변(immutable)**으로 간주하는 것이 좋습니다. let_it_be 선언 내에서 객체를 수정할 때 중요한 주의 사항이 있기 때문입니다(1, 2). let_it_be 객체를 불변으로 만들려면 freeze: true 사용을 고려하세요:
# Before
let_it_be(:namespace) { create_default(:namespace) }
# After
let_it_be(:namespace, freeze: true) { create_default(:namespace) }
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 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 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
팩토리 내에서 메서드 스텁(Stub) 사용#
팩토리에서 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
`stub_method`는 `let_it_be_with_refind`와 함께 사용할 때 동작하지 않습니다. `stub_method`는 인스턴스에 메서드를 스텁하고 `let_it_be_with_refind`는 매 실행마다 새 인스턴스를 생성하기 때문입니다.
stub_method는 메서드 존재 여부 및 메서드 인자 수(arity) 검사를 지원하지 않습니다.
`stub_method`는 팩토리 내에서만 사용하도록 설계되어 있습니다. 다른 곳에서의 사용은 강력히 권장하지 않습니다. 가능한 경우 [RSpec mocks](https://rspec.info/features/3-12/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
테스트 코드가 `project_authorizations` 또는 `Member` 레코드 퍼시스팅에 의존하는 경우 이 스텁 헬퍼 사용을 삼가세요. 대신 `Project#add_member` 또는 `Group#add_member`를 사용하세요.
추가 프로파일링 메트릭#
rspec_profiling gem을 사용하면 테스트 실행 시 만들어지는 SQL 쿼리 수 등을 진단할 수 있습니다.
이는 테스트 중인 부분이 아닌 곳을 mock하는 테스트에 의해 트리거되는 애플리케이션 측 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 spec 실행을 참고하세요.
프로파일링 시 Capybara::DSL# 검색#
stackprof 플레임그래프를 사용할 때, 검색창에서 Capybara::DSL#을 검색하면 실행된 Capybara 액션과 각각 소요된 시간을 확인할 수 있습니다!
느린 테스트 식별#
프로파일링을 활성화하여 spec을 실행하면 spec 최적화를 시작하기에 좋은 방법입니다. 다음과 같이 실행할 수 있습니다:
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
이 결과를 통해 spec에서 가장 비용이 많이 드는 예시를 확인하여 시작점을 찾을 수 있습니다. 여기서 가장 비용이 많이 드는 예시들은 공유 예시(shared examples)에 있으며, 여러 곳에서 호출되기 때문에 개선 시 더 큰 영향을 미칩니다.
비용이 큰 액션 반복 피하기#
격리된 예시는 매우 명확하고 spec을 명세로 사용하는 목적에 부합하지만, 다음 예시는 비용이 큰 액션을 어떻게 결합할 수 있는지 보여줍니다:
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 호출이 비용이 크다면, 서로 다른 assertion을 위해 동일한 액션을 반복하게 됩니다. 예시를 결합하여 이 반복을 줄일 수 있습니다:
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를 사용하는 것을 고려하세요.
그러면 첫 번째 실패만 표시되지 않고 전체 결과를 확인할 수 있습니다.
존재하지 않을 것으로 예상되는 요소에 대한 대기 피하기#
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
조건부 로직에서 대기를 건너뛰려면 wait: 0을 사용하세요.
참고: 조건부 로직을 피할 수 없을 때만 사용하고, 이미 로드되었다고 확인한 영역 내부에서만 사용하세요.
그렇지 않으면 잘못된 결과를 얻을 수 있습니다.
테스트의 조건부 로직은 스펙을 비결정적으로 만듭니다.
within_testid('search-filter') do
click_link 'Edit' if has_link?('Edit', wait: 0)
end
비용이 큰 외부 작업 모킹하기#
실제 외부 프로세스(바이너리 컴파일, Git 명령어 실행, 네트워크 호출)를 트리거하는 Feature 및 통합 스펙은, 테스트 대상 로직이 해당 프로세스를 실제로 실행할 필요가 없는 경우에도 그 프로세스의 전체 wall-clock 비용을 그대로 부담합니다.
프로덕션 코드베이스 수정 사례:
-
실제 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를 검색하거나, the www-gitlab-org 프로젝트를 직접 확인하세요.
기능 카테고리 메타데이터#
각 RSpec 예제에 기능 카테고리 메타데이터를 설정해야 합니다.
EE 라이선스에 따른 테스트#
FOSS_ONLY=1로 실행하는지 여부에 따라 테스트를 실행하려면 컨텍스트/스펙 블록에 if: Gitlab.ee? 또는 unless: Gitlab.ee?를 사용할 수 있습니다.
SaaS에 따른 테스트#
SaaS 전용 기능 테스트에 대한 포괄적인 가이드는 SaaS 전용 기능 테스트 가이드를 참고하세요.
커버리지#
코드 테스트 커버리지 리포트 생성에는 simplecov가 사용됩니다.
CI에서는 자동으로 생성되지만, 로컬에서 테스트를 실행할 때는 생성되지 않습니다.
로컬 머신에서 스펙 파일을 실행할 때 부분 리포트를 생성하려면 SIMPLECOV 환경 변수를 설정하세요:
SIMPLECOV=1 bundle exec rspec spec/models/repository_spec.rb
커버리지 리포트는 앱 루트의 coverage 폴더에 생성되며, 브라우저에서 열 수 있습니다. 예를 들어:
firefox coverage/index.html
커버리지 리포트를 사용하여 테스트가 코드의 100%를 커버하는지 확인하세요.
View 스펙#
spec/views/와 ee/spec/views/의 View 스펙은 렌더링된 HTML 출력을 검증합니다.
백엔드 로직이나 데이터베이스 동작을 재테스트해서는 안 됩니다.
어설션은 have_content, have_css, have_selector, have_link 같은 매처를 사용하여
렌더링된 출력을 타깃으로 해야 합니다.
View 헬퍼 메서드의 내부 Ruby 상태나 반환값에 대해서는 어설션하지 마세요.
설정은 스펙이 실제로 영속 상태를 필요로 하지 않는 한 create 대신 build_stubbed를 사용해야 합니다.
인스턴스 변수를 전달할 때는 assign을 사용하고, 헬퍼 메서드를 스텁할 때는 allow(view).to receive(...)를 사용하세요.
설정은 검증하려는 내용에 비례하게 유지하세요.
인스턴스 변수를 많이 할당하고 여러 헬퍼를 스텁하여 단일 요소를 검증하는 spec은
뷰에 책임이 너무 많다는 신호입니다.
다음 항목은 뷰 spec에 포함하지 마세요:
-
ActiveRecord::QueryRecorder또는exceed_query_limit검증. 쿼리 성능 검증은 요청(request) spec 또는 컨트롤러 spec에서 수행하며, 뷰 spec에서는 하지 않습니다. -
receive_message_chain과 같은 깊은 서비스 객체 목킹 체인. 뷰에서 이런 스텁이 필요하다면, 해당 뷰 자체에 로직이 너무 많은 것입니다.
System / Feature 테스트#
새로운 시스템 테스트를 작성하기 전에,
시스템 수준의 화이트박스 테스트(구 system / feature 테스트)에 관한 가이드를 참고하세요.
-
Feature spec의 파일명은
ROLE_ACTION_spec.rb형식으로 지정하세요. 예:user_changes_password_spec.rb. -
성공 경로와 실패 경로를 설명하는 시나리오 제목을 사용하세요.
-
“successfully”와 같이 정보를 추가하지 않는 시나리오 제목은 피하세요.
-
기능 제목을 반복하는 시나리오 제목은 피하세요.
-
데이터베이스에는 필요한 레코드만 생성하세요.
-
정상 경로(happy path)와 비정상 경로를 하나씩 테스트하는 것으로 충분합니다.
-
그 외의 모든 경로는 단위(Unit) 또는 통합(Integration) 테스트로 검증하세요.
-
ActiveRecord 모델의 내부 상태가 아니라 페이지에 표시되는 내용을 테스트하세요. 예를 들어, 레코드가 생성되었는지 확인하고 싶다면
Model.count가 1 증가했는지가 아니라, 해당 속성이 페이지에 표시되는지를 검증하세요. -
DOM 요소를 탐색하는 것은 괜찮지만 남용하지 마세요. 테스트가 더 취약해질 수 있습니다.
UI 테스트#
UI를 테스트할 때는 사용자가 보는 것과 UI와의 상호작용 방식을 시뮬레이션하는 테스트를 작성하세요. 이는 Capybara의 시맨틱 메서드를 선호하고, ID·클래스·속성으로 조회하는 방식을 피하는 것을 의미합니다.
이 방식으로 테스트하면 다음과 같은 이점이 있습니다:
-
모든 인터랙티브 요소에 접근 가능한 이름(accessible name)이 있는지 보장합니다.
-
보다 자연스러운 언어를 사용하므로 가독성이 높아집니다.
-
사용자에게 보이지 않는 ID·클래스·속성으로 조회하지 않으므로 테스트가 덜 취약해집니다.
ID·클래스명·data-testid 대신 요소의 텍스트 라벨로 조회하는 것을 강력히 권장합니다.
필요하다면 within을 사용하여 페이지의 특정 영역 내에서 상호작용 범위를 지정할 수 있습니다.
일반적으로 레이블이 없는 div 같은 요소를 범위로 지정하는 경우가 많으므로,
이때는 data-testid 셀렉터를 사용할 수 있습니다.
be_axe_clean matcher를 사용하면 feature 테스트에서 axe 자동화 접근성 테스트를 실행할 수 있습니다.
외부화된 콘텐츠#
RSpec 테스트에서 외부화된 콘텐츠에 대한 검증은 번역문을 일치시키기 위해
동일한 외부화 메서드를 호출해야 합니다. 예를 들어 Ruby에서는 _ 메서드를 사용하세요.
자세한 내용은 GitLab 국제화 - 테스트 파일(RSpec)을 참고하세요.
Actions#
가능하면 아래와 같이 보다 구체적인 actions를 사용하세요.
# 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')
Finders#
가능하면 아래와 같이 보다 구체적인 finders를 사용하세요.
# 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()를 호출하여 부모로 거슬러 올라가세요.
Matchers#
가능한 경우 아래와 같이 더 구체적인 매처(matcher)를 사용하세요.
# 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 테스트를 디버그해야 할 수 있습니다.
spec 파일에서 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가 활성화된 spec에서만 동작합니다.
:js spec을 가시적인 브라우저에서 실행하기#
WEBDRIVER_HEADLESS=0을 설정하여 spec을 실행합니다:
WEBDRIVER_HEADLESS=0 bin/rspec some_spec.rb
테스트는 빠르게 완료되지만, 어떤 일이 발생하는지 파악하는 데 도움이 됩니다.
WEBDRIVER_HEADLESS=0과 함께 live_debug를 사용하면 열린 브라우저가 일시 중지되며,
페이지를 다시 열지 않습니다. 이를 통해 요소를 디버그하고 검사할 수 있습니다.
byebug 또는 binding.pry를 추가하여 실행을 일시 중지하고
테스트를 단계별로 실행할 수도 있습니다.
스크린샷#
실패 시 자동으로 스크린샷을 캡처하기 위해 capybara-screenshot gem을 사용합니다.
CI에서는 이 파일들을 job 아티팩트로 다운로드할 수 있습니다.
또한 아래 메서드를 추가하면 테스트 중 어느 시점에서든 수동으로 스크린샷을 찍을 수 있습니다. 더 이상 필요하지 않을 때는 반드시 제거하세요! 자세한 내용은 https://github.com/mattheworiordan/capybara-screenshot#manual-screenshots를 참조하세요.
:js spec에 screenshot_and_save_page를 추가하면 Capybara가 "보는" 화면을 스크린샷으로 캡처하고
페이지 소스를 저장합니다.
:js spec에 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 설정을 건너뜁니다
-
테스트 리포지터리 설정을 건너뜁니다
일반 spec_helper의 경우 30초 이상 걸리는 것에 비해, fast_spec_helper를 사용하는 테스트는
로드하는 데 약 1초가 걸립니다.
fast_spec_helper는 lib/ 디렉터리 내에 위치한 클래스의 자동 로딩도 지원합니다.
클래스나 모듈이 lib/ 디렉터리의 코드만 사용하는 경우,
의존성을 명시적으로 로드할 필요가 없습니다. fast_spec_helper는
Rails 환경에서 일반적으로 사용되는 코어 익스텐션을 포함한 모든 ActiveSupport 익스텐션도 로드합니다.
다만 일부 경우에는, 코드가 gem을 사용하거나 의존성이 lib/에 위치하지 않는 경우
require_dependency를 사용하여 의존성을 직접 로드해야 할 수도 있습니다.
예를 들어, 내부적으로 re2 라이브러리를 사용하는 Gitlab::UntrustedRegexp 클래스를 호출하는 코드를 테스트하려면 다음 중 하나를 수행해야 합니다:
-
re2gem이 필요한 라이브러리 파일에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를 사용하세요.
코드와 스펙이 Rails로부터 잘 격리되어 있는지 확인하려면 `bin/rspec`을 통해 스펙을 개별적으로 실행하세요. `spec_helper`를 자동으로 로드하는 `bin/spring rspec`은 사용하지 마세요.
fast_spec_helper 스펙 유지 관리#
scripts/run-fast-specs.sh라는 유틸리티 스크립트가 있으며, 이를 사용하면 fast_spec_helper를 사용하는 모든 스펙을 다양한 방식으로 실행할 수 있습니다. 이 스크립트는 격리된 환경에서 정상적으로 실행되지 않는 스펙 등, 문제가 있는 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은 지연(lazy) 평가되어 참조되기 전까지 평가되지 않는다는 점을 기억하세요. -
예제 내에서
subject를 직접 참조하지 마세요. 대신 이름이 있는 subjectsubject(: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
이는 spec 파일에 using RSpec::Parameterized::TableSyntax 행을 포함해야 함을 나타냅니다.
`where` 블록의 입력값으로는 단순한 값만 사용하세요. proc, 상태를 가진
객체, FactoryBot으로 생성된 객체 등을 사용하면
예상치 못한 결과가 발생할 수 있습니다.
대신 ref(:symbol)을 사용하세요.
Common test setup#
`let_it_be`와 `before_all`은 DatabaseCleaner의 deletion 전략과 함께 동작하지 않습니다. 여기에는 마이그레이션 spec, Rake task spec, 그리고 `:delete` RSpec 메타데이터 태그를 가진 spec이 포함됩니다.
자세한 내용은 이슈 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 must not depend on a before block#
spec 중간에 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)가 실행될 때 스텁이 설정되지 않아 테스트가 불안정해집니다.
이 예제에서 before를 before_all로 대체할 수 없습니다. 테스트별 라이프사이클 외부에서는 RSpec-mocks의 더블 또는 부분 더블을 사용할 수 없기 때문입니다.
따라서 해결책은 let_it_be(:bar) 대신 let 또는 let!을 사용하는 것입니다.
Time-sensitive tests#
ActiveSupport::Testing::TimeHelpers를
사용하면 시간에 민감한 항목을 검증할 수 있습니다. 시간에 민감한 항목을 실행하거나 검증하는 모든 테스트는
간헐적 테스트 실패를 방지하기 위해 이 헬퍼를 활용해야 합니다.
예시:
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 helpers#
:freeze_time 및 :time_travel_to RSpec 메타데이터 태그 헬퍼를 사용하면 ActiveSupport::Testing::TimeHelpers
메서드로 전체 spec을 감싸는 데 필요한 보일러플레이트 코드의 양을 줄일 수 있습니다.
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로 생성된 객체)는 spec 스코프 밖에 있다는 점을 기억하세요.
모든 것의 시간을 동결해야 하는 경우, before :all을 사용하여 설정을 캡슐화할 수 있습니다.
before :all do
freeze_time
end
after :all do
unfreeze_time
end
Timestamp truncation#
Active Record 타임스탬프는 ActiveRecord::Timestamp
모듈이 Time.now를 사용하여 Rails에서 설정합니다.
시간 정밀도는 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의 블로그 게시물에서 가져왔습니다.
이 문제가 발생한 머지 리퀘스트와 논의된 백엔드 페어링 세션을 참조할 수 있습니다.
Feature flags in tests#
이 섹션은 피처 플래그를 사용한 개발로 이동되었습니다.
깨끗한 테스트 환경#
단일 GitLab 테스트에서 실행되는 코드는 많은 데이터 항목에 접근하고 수정할 수 있습니다. 테스트 실행 전 신중한 준비와 실행 후 정리 없이는, 테스트가 데이터를 변경하여 이후 테스트의 동작에 영향을 미칠 수 있습니다. 이는 어떤 수를 써서라도 피해야 합니다! 다행히 기존 테스트 프레임워크가 대부분의 경우를 이미 처리하고 있습니다.
테스트 환경이 오염되면, 흔히 나타나는 결과는
불안정한 테스트(flaky tests)입니다. 오염은 종종 순서 의존성으로 나타납니다.
spec A 다음에 spec B를 실행하면 안정적으로 실패하지만, spec B 다음에 spec A를 실행하면 안정적으로 성공합니다.
이런 경우 rspec --bisect(또는 spec 파일의 수동 이분 탐색)를 사용하여
어떤 spec이 문제인지 파악할 수 있습니다. 문제를 수정하려면 테스트
스위트가 환경을 깨끗하게 유지하는 방법에 대한 이해가 필요합니다. 각 데이터 저장소에 대해 자세히 알아보세요!
SQL 데이터베이스#
이는 database_cleaner gem이 자동으로 관리합니다. 각 spec은
트랜잭션으로 감싸지며, 테스트 완료 후 롤백됩니다. 일부 spec은
완료 후 모든 테이블에 대해 DELETE FROM 쿼리를 실행합니다. 이를 통해
생성된 행을 여러 데이터베이스 연결에서 조회할 수 있으며,
이는 브라우저에서 실행되는 spec이나 마이그레이션 spec 등에 중요합니다.
잘 알려진 TRUNCATE TABLES 방식 대신 이러한 전략을 사용하는 한 가지 결과는,
기본 키와 기타 시퀀스가 spec 간에 재설정되지 않는다는 것입니다.
따라서 spec A에서 프로젝트를 생성한 후, spec B에서 프로젝트를 생성하면
첫 번째는 id=1, 두 번째는 id=2를 갖습니다.
즉, spec은 ID의 값이나 시퀀스로 생성된 다른 칼럼의 값에 절대로 의존해서는 안 됩니다. 우발적인 충돌을 피하기 위해 spec은 이러한 종류의 칼럼에 값을 수동으로 지정하는 것도 피해야 합니다. 대신, 값을 지정하지 않고 행이 생성된 후 값을 조회하세요.
마이그레이션 spec의 TestProf#
위에서 설명한 이유로 마이그레이션 spec은 데이터베이스 트랜잭션 내에서 실행될 수 없습니다.
테스트 스위트는 런타임을 향상시키기 위해
TestProf를 사용하지만, TestProf는 이러한 최적화를 수행하기 위해 데이터베이스 트랜잭션을 사용합니다.
이 때문에 마이그레이션 spec에서 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)을 사용하세요.
Redis#
GitLab은 두 가지 주요 데이터 카테고리를 Redis에 저장합니다: 캐시된 항목과 Sidekiq
job입니다. 별도의 Redis 인스턴스로 지원되는 Gitlab::Redis::Wrapper 하위 항목의 전체 목록을 확인하세요.
대부분의 spec에서 Rails 캐시는 실제로 인메모리 저장소입니다. 이는
spec 사이에 교체되므로, Rails.cache.read 및 Rails.cache.write 호출은 안전합니다.
그러나 spec이 직접 Redis 호출을 하는 경우,
어떤 Redis 인스턴스를 사용하는지에 따라
:clean_gitlab_redis_cache, :clean_gitlab_redis_shared_state 또는
:clean_gitlab_redis_queues 트레이트로 표시해야 합니다.
백그라운드 job / Sidekiq#
기본적으로 Sidekiq job은 jobs 배열에 추가되며 처리되지 않습니다.
테스트가 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를 상속하지 않기 때문입니다.
DNS#
DNS 요청은 테스트 스위트 전반에 걸쳐 범용적으로 스텁됩니다
(!22368 기준). DNS는
개발자의 로컬 네트워크에 따라 문제를 일으킬 수 있기 때문입니다. spec/support/dns.rb에
RSpec 라벨이 준비되어 있으며, DNS 스텁을 우회해야 하는 테스트에 다음과 같이 적용할 수 있습니다:
it "really connects to Prometheus", :permit_dns do
더 세부적인 제어가 필요한 경우, DNS 차단은
spec/support/helpers/dns_helpers.rb에 구현되어 있으며 이 메서드들을 다른 곳에서 호출할 수 있습니다.
속도 제한(Rate Limiting)#
속도 제한은 테스트 스위트에서 활성화되어 있습니다.
:js 트레이트를 사용하는 기능 spec에서 속도 제한이 트리거될 수 있습니다. 대부분의 경우,
:clean_gitlab_redis_rate_limiting 트레이트로 spec을 표시하면 속도 제한 트리거를 피할 수 있습니다.
이 트레이트는 spec 사이에 Redis 캐시에 저장된 속도 제한 데이터를 지웁니다.
단일 테스트에서 속도 제한이 트리거되는 경우, 대신 :disable_rate_limit을 사용할 수 있습니다.
파일 메서드 스텁#
파일의 내용을
스텁
처리해야 하는 상황에서는 stub_file_read와
expect_file_read 헬퍼 메서드를 사용하세요. 이 메서드들은
File.read에 대한 스텁을 올바르게 처리합니다. 이 메서드들은 주어진
파일명에 대해 File.read를 스텁 처리하고, File.exist?도 true를 반환하도록 스텁 처리합니다.
어떤 이유로 File.read를 수동으로 스텁해야 하는 경우 반드시 다음을 수행하세요:
-
다른 파일 경로에 대해서는 원본 구현을 스텁하고 호출합니다.
-
그런 다음 관심 있는 파일 경로에 대해서만
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 인스턴스는 스펙 간에 공유되지 않지만, 클래스와
모듈은 일반적으로 공유됩니다. 클래스 및 모듈 인스턴스 변수, 접근자,
클래스 변수 및 기타 상태 유지 관용구는 전역 변수와 동일한 방식으로
처리해야 합니다. 꼭 필요한 경우가 아니라면 수정하지 마세요! 특히
수정의 필요성을 피하기 위해 기대값(expectation)이나 스텁과 함께 의존성 주입을
사용하는 것을 선호합니다. 다른 선택지가 없다면 전역 변수 예시와 같은
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에 인덱싱될 때까지 기다립니다.
-
해당 데이터를 검색합니다.
-
테스트가 예상된 결과를 제공하는지 확인합니다.
인덱스의 개별 레코드가 아닌 구조적 변경 사항을 확인하는 것과 같은 일부 예외가 있습니다.
고급 검색을 위한 인덱싱은 [`Gitlab::Redis::SharedState`](/19.1/development/redis/#gitlabrediscachesharedstatequeues)를 사용합니다.
따라서 Elasticsearch 메타데이터는 동적으로 :clean_gitlab_redis_shared_state를 사용합니다.
:clean_gitlab_redis_shared_state를 수동으로 추가할 필요는 없습니다.
Elasticsearch를 사용하는 spec은 다음을 요구합니다:
-
PostgreSQL에 데이터를 생성한 후 Elasticsearch에 인덱싱합니다.
-
Elasticsearch에 대한 Application Settings를 활성화합니다(기본적으로 비활성화되어 있음).
이를 위해 다음을 사용하세요:
before do
stub_ee_application_setting(elasticsearch_search: true, elasticsearch_indexing: true)
end
또한, ensure_elasticsearch_index! 메서드를 사용하여 Elasticsearch의 비동기적 특성을 극복할 수 있습니다.
이 메서드는 Elasticsearch Refresh API를
사용하여 마지막 새로 고침 이후 인덱스에서 수행된 모든 작업을 검색에 사용할 수 있도록 보장합니다. 이 메서드는 일반적으로
PostgreSQL에 데이터를 로드한 후 해당 데이터가 인덱싱되어 검색 가능한 상태인지 확인하기 위해 호출됩니다.
ElasticsearchHelpers의 헬퍼 메서드는 Elasticsearch 메타데이터 중 하나를 사용할 때 자동으로 포함됩니다.
:elastic_helpers 메타데이터를 사용하여 직접 포함시킬 수도 있습니다.
SEARCH_SPEC_BENCHMARK 환경 변수를 사용하여 테스트 설정 단계를 벤치마크할 수 있습니다:
SEARCH_SPEC_BENCHMARK=1 bundle exec rspec ee/spec/lib/elastic/latest/merge_request_class_proxy_spec.rb
레거시 Snowplow 이벤트 테스트#
이 섹션에서는 내부 이벤트로 아직 전환되지 않은 이벤트를 사용하여 테스트하는 방법을 설명합니다.
백엔드#
Snowplow는 [contracts gem](https://rubygems.org/gems/contracts)을 사용하여 **런타임 타입 검사**를 수행합니다.
Snowplow는 테스트 및 개발 환경에서 기본적으로 비활성화되어 있기 때문에, Gitlab::Tracking을 모킹할 때
예외를 잡아내기가 어려울 수 있습니다.
타입 검사로 인한 런타임 오류를 잡으려면 expect_snowplow_event를 사용할 수 있으며,
이는 Gitlab::Tracking#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가 실패할 수 있습니다.
데이터 속성을 사용한 뷰 레이어#
아래에 표시된 `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를 사용할 수 있습니다.
스키마에 대한 Snowplow 컨텍스트 테스트#
Snowplow 스키마 매처는 JSON 스키마에 대해 Snowplow 컨텍스트를 테스트하여 유효성 검사 오류를 줄이는 데 도움이 됩니다. 스키마 매처는 다음 매개변수를 허용합니다:
-
schema path -
context
스키마 매처 스펙을 추가하려면:
Iglu 리포지터리에 새 스키마를 추가한 다음,
동일한 스키마를 spec/fixtures/product_intelligence/ 디렉터리에 복사합니다.
복사된 스키마에서 "$schema" 키와 값을 제거합니다. 스펙에는 필요하지 않으며,
해당 키를 유지하면 URL에서 스키마를 찾으려 하기 때문에 스펙이 실패합니다.
다음 스니펫을 사용하여 스키마 매처를 호출합니다:
match_snowplow_context_schema(schema_path: '<filename from step 1>', context: )
Prometheus 테스트#
Prometheus 메트릭은 테스트 실행 간에 보존될 수 있습니다. 각 예시 실행 전에 메트릭이
초기화되도록 하려면 RSpec 테스트에 :prometheus 태그를 추가합니다.
매처#
커스텀 매처는 RSpec 기대값의 의도를 명확히 하거나 복잡성을 숨기기 위해 만들어야 합니다.
spec/support/matchers/ 아래에 배치해야 합니다. 매처는 특정 유형의 스펙(예: feature 또는
request)에만 적용되는 경우 하위 폴더에 배치할 수 있지만, 여러 유형의 스펙에 적용되는 경우에는
하위 폴더에 배치하지 않아야 합니다.
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)
match_schema 및 match_response_schema#
match_schema 매처는 대상이 JSON 스키마와 일치하는지
유효성을 검사할 수 있게 해줍니다. expect 안의 항목은
JSON 문자열 또는 JSON 호환 데이터 구조입니다.
match_response_schema는 request spec에서 응답 객체와 함께 사용하기 편리한 매처입니다.
예시:
# 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 문제가 존재하지 않음을 검증합니다.
-
코드 블록에서 실행되는 쿼리 수가 인지하지 못한 채 증가하지 않도록 보장합니다.
QueryRecorder#
QueryRecorder는 특정 코드 블록에서 실행되는 데이터베이스 쿼리 수를 프로파일링하고 테스트할 수 있게 해줍니다.
자세한 내용은 QueryRecorder 섹션을 참고하세요.
GitalyClient#
Gitlab::GitalyClient.get_request_count는 특정 코드 블록에서 수행된 Gitaly 쿼리 수를 테스트할 수 있게 해줍니다:
자세한 내용은 Gitaly Request Counts 섹션을 참고하세요.
공유 컨텍스트 및 공유 예시#
하나의 spec 파일에서만 사용되는 공유 컨텍스트나 공유 예시는 인라인으로 선언할 수 있습니다.
두 개 이상의 spec 파일에서 사용되는 공유 예시의 경우, 배치 위치는 범위에 따라 달라집니다:
단일 바운디드 컨텍스트 내의 공유 예시:
-
바운디드 컨텍스트의 디렉터리 구조에 배치할 수 있습니다(예:
ee/spec/requests/api/graphql/remote_development/shared_examples.rb) -
이 방식은 바운디드 컨텍스트의 응집성을 유지하며 모듈형 모노리스 아키텍처와도 일치합니다
여러 바운디드 컨텍스트에서 사용되는 공유 예시:
-
spec/support/shared_*아래에 배치해야 합니다 -
특정 타입의 spec에만 적용되는 경우(예: feature 또는 request) 하위 폴더에 배치할 수 있지만, 여러 타입의 spec에 적용된다면 그렇게 해서는 안 됩니다
일반 가이드라인:
-
공유 예시가 특정 바운디드 컨텍스트 내에서만 사용된다면 해당 컨텍스트에 유지하는 것을 권장합니다
-
실제로 다른 바운디드 컨텍스트 간에 공유될 때만 공유 예시를 전역
spec/support/shared_*디렉터리로 이동합니다 -
공유 예시 및 공유 컨텍스트 파일은 일반적으로
*_contexts.rb,*_examples.rb,*_shared.rb, 또는*_shared_context_and_examples.rb와 같은 네이밍 패턴을 사용합니다 -
목표는 바운디드 컨텍스트 내의 높은 응집성을 유지하면서 컨텍스트 간 결합을 느슨하게 유지하는 것입니다
느린 공유 예시의 성능 영향#
느린 공유 예시는 그 비용이 배수로 증가합니다. 30초가 걸리는 단일 예시가 10개의 spec 파일에 포함되면 30초가 아닌 300초의 CI 시간이 소요됩니다.
코드베이스 전반에 광범위하게 포함되는 spec/support/shared_* 아래의 공유 예시는 단일 spec 예시보다 더 엄격한 성능 기준이 적용됩니다.
가이드라인:
-
공유 컨텍스트로 추출하기 전에 느린 예시를 로컬 spec으로 프로파일링하고 최적화합니다.
-
계약이 명시적으로 데이터베이스 상태를 요구하지 않는 한 공유 예시에서
create호출을 피합니다.build_stubbed또는build를 선호합니다. -
공유 예시에
:js가 필요하다면 UI 검증 부분을 분리하여 나머지를 일반 request spec으로 실행할 수 있는지 고려합니다. -
느리게 광범위하게 포함된 공유 예시를 팩토리 캐스케이드처럼 취급하세요: 한 곳의 작은 수정이 전체 테스트 스위트에 걸쳐 큰 집합적 절감 효과를 가져옵니다. 자세한 내용은 팩토리 사용 최적화를 참조하세요.
Helpers#
Helper는 일반적으로 특정 RSpec 예시의 복잡성을 숨기는 메서드를 제공하는 모듈입니다. 다른 스펙과
공유할 의도가 없다면 RSpec 파일 내에서 helper를 정의할 수 있습니다. 그렇지 않은 경우에는
spec/support/helpers/ 아래에 배치해야 합니다. 특정 유형의 스펙에만 적용되는 경우(예: feature 또는
request) helper를 하위 폴더에 배치할 수 있지만, 여러 유형의 스펙에 적용되는 경우에는 그렇게 하지 않아야 합니다.
Helper는 Rails 네이밍/네임스페이싱 규칙을 따라야 하며,
spec/support/helpers/가 루트 디렉터리입니다. 예를 들어
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
Helper는 RSpec 설정을 변경해서는 안 됩니다. 예를 들어, 위에서 설명한 helper 모듈은 다음을 포함해서는 안 됩니다:
# 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 서비스 전체가 다운되거나, 고객에게 과도한 비용이 청구되거나, 우주가 붕괴될 수 있는 경우가 이에 해당합니다.
Factories#
GitLab은 테스트 픽스처 대체제로 factory_bot을 사용합니다.
팩토리 정의는 spec/factories/에 위치하며, 해당 모델의 복수형을 이름으로 사용합니다
(User 팩토리는 users.rb에 정의됩니다).
파일당 최상위 팩토리 정의는 하나만 있어야 합니다.
특히 커스텀 로직이 사용될 때는 팩토리에 대한 스펙 작성을 고려하세요. 예를 들어 after(:build) 훅의
로직이 이에 해당합니다. 팩토리에 대한 스펙은 spec/factories_specs에 저장됩니다.
FactoryBot 메서드는 모든 RSpec 그룹에 혼합됩니다. 즉, FactoryBot.create(...) 대신
create(...)를 호출할 수 있으며 (그렇게 해야 합니다).
정의와 사용을 정리하기 위해 트레이트(traits)를 활용하세요.
팩토리를 정의할 때는, 결과 레코드가 유효성 검사를 통과하는 데 필요하지 않은 속성은 정의하지 마세요.
팩토리로부터 인스턴스를 생성할 때는, 테스트에 필요하지 않은 속성은 제공하지 마세요.
콜백에서 연관 관계 설정 시 create / build 대신 implicit,
explicit, 또는
inline 연관 관계를 사용하세요.
자세한 맥락은 이슈 #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를 참조하세요.
Fixtures#
모든 fixture는 spec/fixtures/ 하위에 위치해야 합니다.
Repositories#
머지 리퀘스트 병합과 같은 일부 기능을 테스트하려면 테스트 환경에 특정 상태의 Git
리포지터리가 필요합니다. GitLab은 특정 공통 케이스를 위해
gitlab-test 리포지터리를
유지 관리합니다. 프로젝트 팩토리에 :repository 트레이트를 사용하면
해당 리포지터리의 복사본이 사용되도록 할 수 있습니다:
let(:project) { create(:project, :repository) }
가능하면 :repository 대신 :custom_repo 트레이트를 사용하는 것을 고려하세요.
이를 통해 프로젝트 리포지터리의 main 브랜치에 표시될 파일을 정확히 지정할 수 있습니다. 예를 들면:
let(:project) do
create(
:project, :custom_repo,
files: {
'README.md' => 'Content here',
'foo/bar/baz.txt' => 'More content here'
}
)
end
이렇게 하면 기본 권한과 지정된 콘텐츠를 가진 두 개의 파일이 포함된 리포지터리가 생성됩니다.
Configuration#
RSpec 설정 파일은 RSpec 설정을 변경하는 파일입니다(예:
RSpec.configure do |config| 블록). 이 파일들은
spec/support/ 하위에 위치해야 합니다.
각 파일은 spec/support/capybara.rb 또는 spec/support/carrierwave.rb와 같이
특정 도메인과 관련되어야 합니다.
헬퍼 모듈이 특정 종류의 spec에만 적용되는 경우, config.include 호출에 수정자를 추가해야 합니다. 예를 들어
spec/support/helpers/cycle_analytics_helpers.rb가 :lib 및
type: :model spec에만 적용된다면 다음과 같이 작성합니다:
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