차트에 대한 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
    # 또는:
    # 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

위의 스니펫은 여러 테스트 사이에서 공통으로 사용되는 기본값을 설정한 다음, 이를 사용하여 특정 테스트를 위해 HelmTemplate 생성자에 사용될 최종 값에 병합하는 일반적인 패턴을 보여줍니다.

속성 병합 패턴 사용

이 프로젝트의 RSpec 전반에 걸쳐서 다양한 형태의 merge를 찾을 수 있습니다. 병합할 때 고려해야 할 몇 가지 지침과 사항이 있습니다.

루비의 기본 Hash.merge는 대상에서 키를 _교체_하지만, 객체를 깊게 돌아다니지 않습니다. 즉, 소스에 해당 항목이 있는 경우 해당 트리 하위의 모든 속성이 제거됩니다. 이는 모든 트리에서 속성을 제거하려면 해당 트리를 null로 설정해야 한다는 것을 의미합니다. 이 점을 해결하기 위해 우리는 YAML 문서의 단순한 깊은 병합을 수행하기 위해 hash-deep-merge 젬을 사용했습니다. 속성을 _추가_할 때 이것은 잘 작동했습니다. 그러나 이것은 중첩된 구조물을 덮어쓸 수 있는 방법을 제공하지 않는다는 단점이 있습니다.

Helm은 구성 속성을 coalesceValues 함수를 통해 병합/통합하는데, 이는 여기에 구현된 deep_merge와는 매우 다른 동작을 합니다. 우리는 RSpec 내에서 이 함수가 어떻게 작동하는지를 지속적으로 개선하고 있습니다.

일반적인 지침:

  1. Hash.merge의 동작을 인지하고 조심하세요.
  2. hash-deep-merge 젬에서 제공되는 Hash.deep_merge의 동작을 인지하고 조심하세요.
  3. 특정 키를 덮어써야 할 때 명확한 비어있지 않은 내용으로 덮어쓰세요.
  4. 특정 키를 제거해야 할 때 null로 설정하세요.
  5. 명령형 형태(merge!)를 명시적으로 필요하지 않은 경우에는 사용하지 마세요. 사용하는 경우에는 왜 사용하는지 주석으로 설명하세요.

병합 작업을 위한 고려 사항

다음은 Ruby의 Hash.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 내에서 deep_merge와 유사한 동작을 제공하는 것은 원하는 동작입니다. 이론상 Ruby 코드는 다음과 같습니다.

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

첫 번째 관찰: “비어있는” 해시({})를 설정한 경우, Ruby와 Helm 패턴 모두 변경사항이 없습니다. 이는 기본 값 및 “새로운” 값이 동일한 유형이기 때문입니다. 해시를 _제거_하려면 null로 설정해야 합니다.

두 번째 관찰: 이것은 뚜렷한 차이입니다. YAML에서 해시를 null로 설정하면 약간 다른 결과를 얻습니다. Helm은 전체 키를 제거하지만 부모 유형은 그대로 유지합니다. Ruby는 키를 nil 값으로 유지하지만 존재한 상태입니다. 개별 키를 변경하는 경우에도 비슷한 결과를 얻을 수 있습니다. Helm은 이 키를 제거하고 Ruby는 nil 상태로 유지합니다.

마지막으로, 스칼라 값과 맵을 혼동하지 마세요. 다음의 YAML은 Ruby 또는 Helm에서 병합하면 배열이 []로 출력됩니다. 이 기능은 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 클러스터에서 차트를 인스턴스화하는 YAML 문서를 작성하는 데 사용된 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 디렉터리에 배치해야 합니다.

만약 RSpec 테스트가 실행되고 Kubernetes 클러스터에 액세스할 수 없는 경우, features 디렉터리의 테스트는 건너뛰어질 것입니다. 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 호출 수가 줄어 시간을 상당히 절약할 수 있습니다.