차트에 대한 RSpec 테스트 작성

다음은 GitLab 차트에 대한 RSpec 테스트를 작성하는 데 사용되는 주석과 관습입니다.

RSpec 테스트 필터링

개발을 지원하기 위해 하나 이상의 테스트에 :focus 태그를 추가하여 실행할 테스트를 필터링하는 것이 가능합니다. :focus 태그가 있는 테스트만 실행됩니다. 이를 통해 새 코드의 빠른 개발 및 테스트가 가능하고, 모든 RSpec 테스트가 실행되기를 기다릴 필요가 없습니다. 다음은 :focus로 태그가 지정된 테스트의 예시입니다.

describe 'some feature' do
  it 'generates output', :focus => true do
    ...
  end
end

describe, context 또는 it 블록에 :focus 태그를 추가할 수 있어서, 테스트 또는 테스트 그룹을 실행할 수 있습니다.

차트에서 YAML 생성

차트의 테스트의 일부는 차트 입력의 수에 따라 올바른 YAML 구조를 생성하는지 확인하는 것입니다. 이는 다음과 같은 HelmTemplate 클래스를 사용하여 수행됩니다.

obj = HelmTemplate.new(values)

결과적으로 objhelm template 명령에 의해 반환된 YAML 문서를 Kubernetes 객체 kind 및 객체 이름(metadata.name)으로 색인화한 것입니다. 이 색인화된 값은 YAML 내에서 값을 찾기 위해 대부분의 메소드에서 사용됩니다.

예를 들어:

obj.dig('ConfigMap/test-gitaly', 'data', 'config.toml.tpl')

이렇게 하면 test-gitaly ConfigMap에 포함된 config.toml.tpl 파일의 내용이 반환됩니다.

참고: HelmTemplate 클래스를 사용할 때 항상 helm template 명령을 실행할 때 “test” 릴리스 이름이 사용됩니다.

차트 입력

HelmTemplate 클래스 생성자에 대한 입력 매개변수는 Helm 명령줄에서 사용되는 values.yaml을 나타내는 값 사전입니다. 이 사전은 values.yaml 파일의 YAML 구조를 반영합니다.

describe 'some feature' do
  let(:default_values) do
    HelmTemplate.defaults
    # or:
    # HelmTemplate.with_defaults(%(
    #  yourCustom: values
    #))
  end

  describe 'global.feature.enabled' do
    let(:values) do
      YAML.safe_load(%(
        global:
          feature:
            enabled: true
      )).deep_merge(default_values)
    end

    ...
  end
end

위의 코드 조각은 공통으로 사용되는 여러 개의 기본 값을 설정한 다음, 최종 값으로 병합되는 여러 테스트 간에 공통된 패턴을 보여줍니다.

속성 병합 패턴 사용

이 프로젝트의 RSpec에서 merge의 다른 형태를 찾을 수 있습니다. 사용할 때 고려해야 할 몇 가지 지침과 사항이 있습니다.

루비의 원시 Hash.merge는 대상에서 키를 _교체_하지만 객체를 깊게 이동하지는 않습니다. 즉, 소스에 일치하는 항목이 있는 경우 모든 속성이 제거됩니다. 이는 모든 속성을 _추가_할 때 잘 작동합니다. 단점은 중첩된 구조를 덮어쓸 방법을 제공하지 않는다는 것입니다.

헬름은 설정 속성을 coalesceValues 함수를 통해 병합/통합하며, 이는 여기에서 구현된 deep_merge와는 매우 다른 동작을 합니다. 우리는 RSpec에서의 이 기능을 지속적으로 개선하고 있습니다.

일반적인 지침:

  1. Hash.merge의 동작을 인식하고 경계해야 합니다.
  2. hash-deep-merge 젬으로 제공되는 Hash.deep_merge의 동작을 인식하고 경계해야 합니다.
  3. 특정 키를 덮어써야 하는 경우 비어 있지 않은 내용으로 명시적으로 이렇게 해야 합니다.
  4. 특정 키를 제거해야 할 경우 null로 설정해야 합니다.
  5. 명령형 형식(merge!)은 명시적으로 필요하지 않은 한 사용하지 마십시오. 그렇게 할 경우 왜 그렇게 하는지 설명하세요.

병합 작업에 대한 고려 사항 분석

다음은 루비의 Hash.mergeHash.deep_mergehash-deep-merge 젬의 기능과 직접 비교한 것입니다.

2.7.2 :002 > require 'yaml'
 => true
2.7.2 :003"> example = YAML.safe_load(%(
2.7.2 :004">   a:
2.7.2 :005">     b: 1
2.7.2 :006">     c: [ 1, 2, 3]
2.7.2 :007 >  ))
 => {"a"=>{"b"=>1, "c"=>[1, 2, 3]}}
2.7.2 :008"> source = YAML.safe_load(%(
2.7.2 :009">   a:
2.7.2 :010">     d: "whee"
2.7.2 :011 >  ))
 => {"a"=>{"d"=>"whee"}}
2.7.2 :012 > example.merge(source)
 => {"a"=>{"d"=>"whee"}}
2.7.2 :013 > require 'hash_deep_merge'
2.7.2 :014 > example = {"a"=>{"b"=>1, "c"=>[1, 2, 3]}}
 => {"a"=>{"b"=>1, "c"=>[1, 2, 3]}}
2.7.2 :015 > source = {"a"=>{"b"=> 2, "d"=>"whee"}}
 => {"a"=>{"b"=>2, "d"=>"whee"}}
2.7.2 :016 > example.deep_merge(source)
 => {"a"=>{"b"=>2, "c"=>[1, 2, 3], "d"=>"whee"}}

여기서는 Ruby의 values.deep_merge(xyz)와 Helm의 helm template . -f xyz.yaml의 출력을 비교하여 deep_merge와 Helm 내의 coalesceValues 간의 차이를 살펴본 후 구분하도록 합니다. 원하는 동작은 Helm 내에서 사용되는 Go 모듈인 github.com/darccio/mergomerge.WithOverride와 동등하게 하는 것입니다.

이러한 경우 루비 코드는 사실상 다음과 같습니다.

require 'yaml'
require 'hash_deep_merge'

values = YAML.safe_load(File.read('values.yaml'))
xyz = YAML.safe_load(File.read('xyz.yaml'))

puts values.deep_merge(xyz).to_yaml
---
file: values.yaml
gitlab:
  gitaly:
    securityContext:
      user: 1000
      group: 1000
---
file: empty.yaml     # `securityContext: {}` 설정
gitlab:
  gitaly:
    securityContext:
      user: 1000
      group: 1000
---
file: null.yaml      # `securityContext: null` 설정
gitlab:
  gitaly:
    securityContext:
---
file: null_user.yaml # `securityContext.user: null` 설정
gitlab:
  gitaly:
    securityContext:
      user:
      group: 1000

Helm 템플릿은 {{ .Values | toYaml }}만 포함합니다.

---
# Source: example/templates/output.yaml
file: values.yaml
gitlab:
  gitaly:
    securityContext:
      group: 1000
      user: 1000
---
# Source: example/templates/output.yaml
file: empty.yaml     # `securityContext: {}` 설정
gitlab:
  gitaly:
    securityContext:
      group: 1000
      user: 1000
---
# Source: example/templates/output.yaml
file: null.yaml      # `securityContext: null` 설정
gitlab:
  gitaly: {}
---
# Source: example/templates/output.yaml
file: null_user.yaml # `securityContext.user: null` 설정
gitlab:
  gitaly:
    securityContext:
      group: 1000

첫 번째 관측: “빈” 해시({})를 설정할 때, 루비 및 헬름 패턴은 변경이 없습니다. 이는 기본 값과 “새로운” 값이 동일한 유형이기 때문입니다. 해시를 제거하려면 null로 설정해야 합니다.

두 번째 관측: 이것은 명백한 차이점입니다. YAML에서 해시를 null로 설정하면 결과가 약간 다릅니다. 헬름은 전체 키를 제거하지만 부모 유형은 그대로 유지합니다. 루비는 키를 nil 값으로 유지하면서 키를 유지하는 것을 볼 수 있습니다. 개별 키를 변경할 때도 비슷한 결과를 얻을 수 있습니다. 헬름은 이 키를 제거하고 루비는 nil 상태로 유지합니다.

마지막으로 스칼라와 맵을 혼동하지 마십시오. 다음 YAML은 루비나 헬름에서 병합되면 배열이 []로 설정됩니다. deep_mergecoalesceValues는 배열을 탐색하지 않습니다. 스칼라 데이터는 덮어쓰여집니다.

---
complex:
  array: [1,2,3]
  hash:
    item: 1
---
complex:
  array: []
  hash:
    item:
---
# Ruby: puts values.deep_merge(xyz).to_yaml
complex:
  array: []
  hash:
    item:
---
# Source: example/templates/output.yaml
complex:
  array: []
  hash: {}

결과 테스트

HelmTemplate 객체에는 RSpec 테스트 작성을 돕는 여러 가지 메서드가 있습니다. 다음은 사용 가능한 메서드의 요약입니다.

  • .exit_code() : Kubernetes 클러스터에서 차트를 생성하는 데 사용된 helm template 명령의 종료 코드를 반환합니다. helm template의 성공적인 완료는 종료 코드 0을 반환합니다.

  • .dig(key, ...) : HelmTemplate 인스턴스에서 반환된 YAML 문서를 따라 이동하고 마지막 키에 머무는 값을 반환합니다. 값을 찾을 수 없는 경우 nil이 반환됩니다.

  • .labels(item) : 지정된 객체의 라벨 해시를 반환합니다.

  • .template_labels(item) : 지정된 객체에 대한 템플릿 구조에서 사용된 라벨 해시를 반환합니다. 지정된 객체는 배포(Deployment), StatefulSet 또는 CronJob 객체여야 합니다.

  • .annotations(item) : 지정된 객체의 주석 해시를 반환합니다.

  • .template_annotations(item) : 지정된 객체에 대한 템플릿 구조에서 사용된 주석 해시를 반환합니다. 지정된 객체는 배포(Deployment), StatefulSet 또는 CronJob 객체여야 합니다.

  • .volumes(item) : 지정된 배포 객체의 모든 볼륨 배열을 반환합니다. 반환된 배열은 배포 객체의 volumes 키의 직접적인 복사본입니다.

  • .find_volume(item, volume_name) : 지정된 배포 객체에서 지정된 볼륨의 사전을 반환합니다.

  • .projected_volume_sources(item, mount_name) : 지정된 투영된 볼륨의 소스 배열을 반환합니다. 반환된 배열은 다음과 같은 구조를 가지고 있습니다:

    - secret:
        name: test-rails-secret
        items:
         - key: secrets.yml
           path: rails-secrets/secrets.yml
    
  • .stderr() : helm template 명령의 실행에서 STDERR 출력을 반환합니다.

  • .values() : helm template 명령의 실행에 사용된 모든 값의 사전을 반환합니다.

Kubernetes 클러스터를 필요로 하는 테스트

대부분의 RSpec 테스트는 helm template을 실행한 다음 생성된 YAML을 분석하여 테스트되는 기능에 맞는 올바른 구조를 확인합니다. 때로는 특정 RSpec 테스트가 Kubernetes 클러스터에 배포된 GitLab Helm 차트에 액세스해야 합니다. Kubernetes 클러스터에 배포된 차트와 상호작용하는 테스트는 features 디렉토리에 배치되어야 합니다.

만약 Kubernetes 클러스터를 사용할 수 없는 경우 helm template의 실행에서 테스트가 생략됩니다. RSpec 실행 시 kubectl get nodes가 확인되고 성공적으로 반환되면 features 디렉토리의 테스트가 포함됩니다.

테스트 속도 최적화

it 블록은 Helm 템플릿을 실행하는데 시간과 리소스가 많이 소요됩니다. RSpec 테스트 스위트에서 이러한 블록이 빈번하기 때문에 가능한 경우 it 블록의 수를 줄이기를 목표로 하고 있습니다.

RSpec documentation은 다음과 같이 설명합니다:

let을 사용하여 캐시된 도우미 메서드를 정의합니다. 값은 동일한 예에서 여러 번 호출되더라도 여러 예 사이에서는 캐시되지 않습니다.

예를 들어, 이 테스트 리팩터를 고려해 보십시오:

이전: ~14초 소요

let(:template) { HelmTemplate.new(deployments_values) }

it 'properly sets the global ingress provider when not specified' do
  expect(template.annotations('Ingress/test-webservice-default')).to include('kubernetes.io/ingress.provider' => 'global-provider')
end

it 'properly sets the local ingress provider when specified' do
  expect(template.annotations('Ingress/test-webservice-second')).to include('kubernetes.io/ingress.provider' => 'second-provider')
end

이후: ~5초 소요

let(:template) { HelmTemplate.new(deployments_values) }

it 'properly sets the ingress provider' do
  expect(template.annotations('Ingress/test-webservice-default')).to include('kubernetes.io/ingress.provider' => 'global-provider')
  expect(template.annotations('Ingress/test-webservice-second')).to include('kubernetes.io/ingress.provider' => 'second-provider')
end

두 개의 it 블록을 하나로 통합하면 helm template 호출 횟수가 줄어 시간을 크게 절약할 수 있습니다.