JUnit in GitLab CI with Kubernetes

Despite the fact that everyone knows perfectly well that it is important and necessary to test your software, and many have been doing it automatically for a long time, in the vastness of Habr there was not a single recipe for setting up a bunch of such popular products in this niche as (we love) GitLab and JUnit . Let's fill this gap!

JUnit in GitLab CI with Kubernetes

Introductory

Let me start with the context:

  • Since all of our applications run on Kubernetes, running the tests on the appropriate infrastructure will be considered.
  • To build and deploy we use yard (in terms of infrastructure components, this also automatically means that Helm is involved).
  • I will not go into the details of directly creating tests: in our case, the client writes the tests himself, and we only ensure their launch (and the presence of the corresponding report in the merge request).


What will the general sequence of actions look like?

  1. Assembly of the application - we will omit the description of this stage.
  2. Deploy the application to a separate namespace of the Kubernetes cluster and start testing.
  3. Finding artifacts and parsing a JUnit report by GitLab.
  4. Removing the previously created namespace'a.

Now - to implementation!

Setting

GitLab CI

Let's start with a snippet .gitlab-ci.yaml, which describes the deployment of the application and the launch of tests. The listing turned out to be quite voluminous, therefore it is thoroughly supplemented with comments:

variables:
# объявляСм Π²Π΅Ρ€ΡΠΈΡŽ werf, ΠΊΠΎΡ‚ΠΎΡ€ΡƒΡŽ собираСмся ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚ΡŒ
  WERF_VERSION: "1.0 beta"

.base_deploy: &base_deploy
  script:
# создаСм namespace Π² K8s, Ссли Π΅Π³ΠΎ Π½Π΅Ρ‚
    - kubectl --context="${WERF_KUBE_CONTEXT}" get ns ${CI_ENVIRONMENT_SLUG} || kubectl create ns ${CI_ENVIRONMENT_SLUG}
# Π·Π°Π³Ρ€ΡƒΠΆΠ°Π΅ΠΌ werf ΠΈ Π΄Π΅ΠΏΠ»ΠΎΠΈΠΌ β€” ΠΏΠΎΠ΄Ρ€ΠΎΠ±Π½Π΅Π΅ ΠΎΠ± этом см. Π² Π΄ΠΎΠΊΡƒΠΌΠ΅Π½Ρ‚Π°Ρ†ΠΈΠΈ
# (https://werf.io/how_to/gitlab_ci_cd_integration.html#deploy-stage)
    - type multiwerf && source <(multiwerf use ${WERF_VERSION})
    - werf version
    - type werf && source <(werf ci-env gitlab --tagging-strategy tag-or-branch --verbose)
    - werf deploy --stages-storage :local
      --namespace ${CI_ENVIRONMENT_SLUG}
      --set "global.commit_ref_slug=${CI_COMMIT_REF_SLUG:-''}"
# ΠΏΠ΅Ρ€Π΅Π΄Π°Π΅ΠΌ ΠΏΠ΅Ρ€Π΅ΠΌΠ΅Π½Π½ΡƒΡŽ `run_tests`
# ΠΎΠ½Π° Π±ΡƒΠ΄Π΅Ρ‚ ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚ΡŒΡΡ Π² Ρ€Π΅Π½Π΄Π΅Ρ€Π΅ Helm-Ρ€Π΅Π»ΠΈΠ·Π°
      --set "global.run_tests=${RUN_TESTS:-no}"
      --set "global.env=${CI_ENVIRONMENT_SLUG}"
# измСняСм timeout (Π±Ρ‹Π²Π°ΡŽΡ‚ Π΄ΠΎΠ»Π³ΠΈΠ΅ тСсты) ΠΈ ΠΏΠ΅Ρ€Π΅Π΄Π°Π΅ΠΌ Π΅Π³ΠΎ Π² Ρ€Π΅Π»ΠΈΠ·
      --set "global.ci_timeout=${CI_TIMEOUT:-900}"
     --timeout ${CI_TIMEOUT:-900}
  dependencies:
    - Build

.test-base: &test-base
  extends: .base_deploy
  before_script:
# создаСм Π΄ΠΈΡ€Π΅ΠΊΡ‚ΠΎΡ€ΠΈΡŽ для Π±ΡƒΠ΄ΡƒΡ‰Π΅Π³ΠΎ ΠΎΡ‚Ρ‡Π΅Ρ‚Π°, исходя ΠΈΠ· $CI_COMMIT_REF_SLUG
    - mkdir /mnt/tests/${CI_COMMIT_REF_SLUG} || true
# Π²Ρ‹Π½ΡƒΠΆΠ΄Π΅Π½Π½Ρ‹ΠΉ ΠΊΠΎΡΡ‚Ρ‹Π»ΡŒ, Ρ‚.ΠΊ. GitLab Ρ…ΠΎΡ‡Π΅Ρ‚ ΠΏΠΎΠ»ΡƒΡ‡ΠΈΡ‚ΡŒ Π°Ρ€Ρ‚Π΅Ρ„Π°ΠΊΡ‚Ρ‹ Π² своСм build-dir’С
    - mkdir ./tests || true
    - ln -s /mnt/tests/${CI_COMMIT_REF_SLUG} ./tests/${CI_COMMIT_REF_SLUG}
  after_script:
# послС окончания тСстов удаляСм Ρ€Π΅Π»ΠΈΠ· вмСстС с Job’ом
# (ΠΈ, Π²ΠΎΠ·ΠΌΠΎΠΆΠ½ΠΎ, Π΅Π³ΠΎ инфраструктурой)
    - type multiwerf && source <(multiwerf use ${WERF_VERSION})
    - werf version
    - type werf && source <(werf ci-env gitlab --tagging-strategy tag-or-branch --verbose)
    - werf dismiss --namespace ${CI_ENVIRONMENT_SLUG} --with-namespace
# ΠΌΡ‹ Ρ€Π°Π·Ρ€Π΅ΡˆΠ°Π΅ΠΌ падСния, Π½ΠΎ Π²Ρ‹ ΠΌΠΎΠΆΠ΅Ρ‚Π΅ ΡΠ΄Π΅Π»Π°Ρ‚ΡŒ ΠΈΠ½Π°Ρ‡Π΅
  allow_failure: true
  variables:
    RUN_TESTS: 'yes'
# Π·Π°Π΄Π°Π΅ΠΌ контСкст Π² werf
# (https://werf.io/how_to/gitlab_ci_cd_integration.html#infrastructure)
    WERF_KUBE_CONTEXT: 'admin@stage-cluster'
  tags:
# ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠ΅ΠΌ Ρ€Π°Π½Π½Π΅Ρ€ с Ρ‚Π΅Π³ΠΎΠΌ `werf-runner`
    - werf-runner
  artifacts:
# трСбуСтся ΡΠΎΠ±Ρ€Π°Ρ‚ΡŒ Π°Ρ€Ρ‚Π΅Ρ„Π°ΠΊΡ‚ для Ρ‚ΠΎΠ³ΠΎ, Ρ‡Ρ‚ΠΎΠ±Ρ‹ Π΅Π³ΠΎ ΠΌΠΎΠΆΠ½ΠΎ Π±Ρ‹Π»ΠΎ ΡƒΠ²ΠΈΠ΄Π΅Ρ‚ΡŒ
# Π² ΠΏΠ°ΠΉΠΏΠ»Π°ΠΉΠ½Π΅ ΠΈ ΡΠΊΠ°Ρ‡Π°Ρ‚ΡŒ β€” Π½Π°ΠΏΡ€ΠΈΠΌΠ΅Ρ€, для Π±ΠΎΠ»Π΅Π΅ Π²Π΄ΡƒΠΌΡ‡ΠΈΠ²ΠΎΠ³ΠΎ изучСния
    paths:
      - ./tests/${CI_COMMIT_REF_SLUG}/*
# Π°Ρ€Ρ‚Π΅Ρ„Π°ΠΊΡ‚Ρ‹ ΡΡ‚Π°Ρ€ΡˆΠ΅ Π½Π΅Π΄Π΅Π»ΠΈ Π±ΡƒΠ΄ΡƒΡ‚ ΡƒΠ΄Π°Π»Π΅Π½Ρ‹
    expire_in: 7 day
# Π²Π°ΠΆΠ½ΠΎ: эти строки ΠΎΡ‚Π²Π΅Ρ‡Π°ΡŽΡ‚ Π·Π° парсинг ΠΎΡ‚Ρ‡Π΅Ρ‚Π° GitLab’ом
    reports:
      junit: ./tests/${CI_COMMIT_REF_SLUG}/report.xml

# для упрощСния здСсь ΠΏΠΎΠΊΠ°Π·Π°Π½Ρ‹ всСго Π΄Π²Π΅ стадии
# Π² Ρ€Π΅Π°Π»ΡŒΠ½ΠΎΡΡ‚ΠΈ ΠΆΠ΅ Ρƒ вас ΠΈΡ… Π±ΡƒΠ΄Π΅Ρ‚ большС β€” ΠΊΠ°ΠΊ ΠΌΠΈΠ½ΠΈΠΌΡƒΠΌ ΠΈΠ·-Π·Π° дСплоя
stages:
  - build
  - tests

build:
  stage: build
  script:
# сборка β€” снова ΠΏΠΎ Π΄ΠΎΠΊΡƒΠΌΠ΅Π½Ρ‚Π°Ρ†ΠΈΠΈ ΠΏΠΎ werf
# (https://werf.io/how_to/gitlab_ci_cd_integration.html#build-stage)
    - type multiwerf && source <(multiwerf use ${WERF_VERSION})
    - werf version
    - type werf && source <(werf ci-env gitlab --tagging-strategy tag-or-branch --verbose)
    - werf build-and-publish --stages-storage :local
  tags:
    - werf-runner
  except:
    - schedules

run tests:
  <<: *test-base
  environment:
# "сама соль" имСнования namespace’а
# (https://docs.gitlab.com/ce/ci/variables/predefined_variables.html)
    name: tests-${CI_COMMIT_REF_SLUG}
  stage: tests
  except:
    - schedules

Kubernetes

Now in the directory .helm/templates create YAML with Job - tests-job.yaml - to run tests and the Kubernetes resources it needs. See explanation after listing:

{{- if eq .Values.global.run_tests "yes" }}
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: tests-script
data:
  tests.sh: |
    echo "======================"
    echo "${APP_NAME} TESTS"
    echo "======================"

    cd /app
    npm run test:ci
    cp report.xml /app/test_results/${CI_COMMIT_REF_SLUG}/

    echo ""
    echo ""
    echo ""

    chown -R 999:999 /app/test_results/${CI_COMMIT_REF_SLUG}
---
apiVersion: batch/v1
kind: Job
metadata:
  name: {{ .Chart.Name }}-test
  annotations:
    "helm.sh/hook": post-install,post-upgrade
    "helm.sh/hook-weight": "2"
    "werf/watch-logs": "true"
spec:
  activeDeadlineSeconds: {{ .Values.global.ci_timeout }}
  backoffLimit: 1
  template:
    metadata:
      name: {{ .Chart.Name }}-test
    spec:
      containers:
      - name: test
        command: ['bash', '-c', '/app/tests.sh']
{{ tuple "application" . | include "werf_container_image" | indent 8 }}
        env:
        - name: env
          value: {{ .Values.global.env }}
        - name: CI_COMMIT_REF_SLUG
          value: {{ .Values.global.commit_ref_slug }}
       - name: APP_NAME
          value: {{ .Chart.Name }}
{{ tuple "application" . | include "werf_container_env" | indent 8 }}
        volumeMounts:
        - mountPath: /app/test_results/
          name: data
        - mountPath: /app/tests.sh
          name: tests-script
          subPath: tests.sh
      tolerations:
      - key: dedicated
        operator: Exists
      - key: node-role.kubernetes.io/master
        operator: Exists
      restartPolicy: OnFailure
      volumes:
      - name: data
        persistentVolumeClaim:
          claimName: {{ .Chart.Name }}-pvc
      - name: tests-script
        configMap:
          name: tests-script
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: {{ .Chart.Name }}-pvc
spec:
  accessModes:
  - ReadWriteOnce
  resources:
    requests:
      storage: 10Mi
  storageClassName: {{ .Chart.Name }}-{{ .Values.global.commit_ref_slug }}
  volumeName: {{ .Values.global.commit_ref_slug }}

---
apiVersion: v1
kind: PersistentVolume
metadata:
  name: {{ .Values.global.commit_ref_slug }}
spec:
  accessModes:
  - ReadWriteOnce
  capacity:
    storage: 10Mi
  local:
    path: /mnt/tests/
  nodeAffinity:
   required:
     nodeSelectorTerms:
     - matchExpressions:
       - key: kubernetes.io/hostname
         operator: In
         values:
         - kube-master
  persistentVolumeReclaimPolicy: Delete
  storageClassName: {{ .Chart.Name }}-{{ .Values.global.commit_ref_slug }}
{{- end }}

What are the resources described in this configuration? When deploying, we create a unique namespace for the project (this is also indicated in .gitlab-ci.yaml tests-${CI_COMMIT_REF_SLUG}) and roll it into it:

  1. ConfigMap with test script;
  2. Jobs with pod description and specified directive command, which just runs the tests;
  3. PV and PVC, which allows you to store test data.

Pay attention to the introductory condition with if at the beginning of the manifest - accordingly, other YAML files of the Helm chart with the application must be wrapped in inverse construction so that they do not deploy during testing. That is:

{{- if ne .Values.global.run_tests "yes" }}
---
я Π΄Ρ€ΡƒΠ³ΠΎΠΉ ямлик
{{- end }}

However, if tests require some infrastructure (for example, Redis, RabbitMQ, Mongo, PostgreSQL ...) - their YAMLs can be not turn off. Deploy them in a test environment ... of course, tweaking as you see fit.

Final touch

Because build and deploy with werf works so far only on the build server (with gitlab-runner), and the pod with tests is launched on the master, you need to create a directory /mnt/tests on the master and give it to the runner, e.g. via NFS. An expanded example with explanations can be found in documentation K8s.

The result will be:

user@kube-master:~$ cat /etc/exports | grep tests
/mnt/tests    IP_gitlab-builder/32(rw,nohide,insecure,no_subtree_check,sync,all_squash,anonuid=999,anongid=998)

user@gitlab-runner:~$ cat /etc/fstab | grep tests
IP_kube-master:/mnt/tests    /mnt/tests   nfs4    _netdev,auto  0       0

Nobody forbids to make an NFS-share right on the gitlab-runner, and then mount it in pods.

Note

You may be asking why complicate things at all by creating a Job when you can just run the script with the tests right on the shell runner? The answer is rather trivial...

Some tests require access to the infrastructure (MongoDB, RabbitMQ, PostgreSQL, etc.) to check the correctness of working with them. We make testing unified - with this approach, it becomes easy to include such additional entities. In addition to this, we get standard deployment approach (even if using NFS, additional mounting of directories).

Experience the Power of Effective Results

What will we see when we apply the prepared configuration?

The merge request will show summary statistics for tests run in its latest pipeline:

JUnit in GitLab CI with Kubernetes

Each error can be clicked here for details:

JUnit in GitLab CI with Kubernetes

NB: An attentive reader will notice that we are testing a NodeJS application, and in the screenshots - .NET ... Do not be surprised: just as part of the preparation of the article, there were no errors in testing the first application, but they were found in another.

Conclusion

As you can see, nothing complicated!

In principle, if you already have a shell builder and it works, but you don’t need Kubernetes, attaching testing to it will be an even easier task than described here. And in GitLab CI documentation you'll find examples for Ruby, Go, Gradle, Maven, and more.

P.S.

Read also on our blog:

Source: habr.com

Buy reliable hosting for sites with DDoS protection, VPS VDS servers πŸ”₯ Buy reliable website hosting with DDoS protection, VPS VDS servers | ProHoster