Skip to main content

Как запустить C# юнит-тесты на Linux в GitHub Actions?

· 7 min read

Бывает, что даже Java или C++-разработчику нужно проверить (или показать пример), как с его сервисом будут работать клиенты, написанные на различных, порой экзотических, языках. В этой статье расскажу о своём опыте запуска C#-тестов на Linux и последующую публикацию их в открытом репозитории кода на GitHub.

Устанавливаем .NET на Ubuntu 22.04

Согласно документации от Microsoft, .NET 7.0 на момент написания поста пока ещё нельзя установить стандартным менеджером пакетов Ubuntu из дефолтных репозиториев, поэтому, действуя по инструкции с сайта, сперва устанавливаем пакет с сайта Microsoft с репозиториями и ключами подписи пакетов.

# Get Ubuntu version
$ declare repo_version=$(if command -v lsb_release &> /dev/null; then lsb_release -r -s; else grep -oP '(?<=^VERSION_ID=).+' /etc/os-release | tr -d '"'; fi)

# Download Microsoft signing key and repository
$ wget https://packages.microsoft.com/config/ubuntu/$repo_version/packages-microsoft-prod.deb -O packages-microsoft-prod.deb

# Install Microsoft signing key and repository
$ sudo dpkg -i packages-microsoft-prod.deb

# Clean up
$ rm packages-microsoft-prod.deb

# Update packages
$ sudo apt update

Так как мы будем разрабатывать приложения, а не только запускать их, нам необходимо установить .NET SDK (в отличие от .NET Runtime, который позволяет только запускать уже собранные приложения).

$ sudo apt install dotnet-sdk-7.0

Проверяем, что команда запуска dotnet теперь работает:

$ dotnet

Usage: dotnet [options]
Usage: dotnet [path-to-application]

Options:
-h|--help Display help.
--info Display .NET information.
--list-sdks Display the installed SDKs.
--list-runtimes Display the installed runtimes.

path-to-application:
The path to an application .dll file to execute.

Создаём проект

Напишем простой юнит-тест. Сначала создаём новый проект по шаблону mstest.

$ dotnet new mstest

Слегка модифицируем тест, чтобы он хоть что-нибудь делал.

UnitTest1.cs
namespace c_sharp_unit_tests_github_actions;

[TestClass]
public class UnitTest1
{
[TestMethod]
public void TestMethod1()
{
Assert.IsTrue(true);
}
}

Проверяем, что локально тест проходит.

$ dotnet test
Determining projects to restore...
All projects are up-to-date for restore.
c-sharp-unit-tests-github-actions -> /home/user/c-sharp-unit-tests-github-actions/bin/Debug/net7.0/c-sharp-unit-tests-github-actions.dll
Test run for /home/user/c-sharp-unit-tests-github-actions/bin/Debug/net7.0/c-sharp-unit-tests-github-actions.dll (.NETCoreApp,Version=v7.0)
Microsoft (R) Test Execution Command Line Tool Version 17.5.0 (x64)
Copyright (c) Microsoft Corporation. All rights reserved.

Starting test execution, please wait...
A total of 1 test files matched the specified pattern.

Passed! - Failed: 0, Passed: 1, Skipped: 0, Total: 1, Duration: 10 ms - c-sharp-unit-tests-github-actions.dll (net7.0)

Запускаем сборку в GitHub Actions

Для запуска сборки .NET-проектов у GitHub есть готовая страничка с быстрым стартом. Как и для любой другой сборки нужно только создать новый workflow в каталоге .github/workflows проекта с yml-файлом, содержащим описание шагов сборки. В нашем случае порядок сборки довольно стандартен:

  • выкачиваем код проекта из репозитория,
  • подготавливаем окружение для сборки с нужной нам версией .NET (я оставил только версию 7.0),
  • запускаем сборку проекта,
  • выполняем тесты.
.github/workflows/build-dotnet.yml
name: dotnet test

on: [push]

jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
dotnet-version: [ '7.0.x' ]

steps:
- uses: actions/checkout@v3
- name: Setup .NET Core SDK ${{ matrix.dotnet-version }}
uses: actions/setup-dotnet@v3
with:
dotnet-version: ${{ matrix.dotnet-version }}
- name: Install dependencies
run: dotnet restore
- name: Build
run: dotnet build --configuration Release --no-restore
- name: Test
run: dotnet test --no-restore --verbosity normal

Коммитим код проекта и убеждаемся, что сборка успешно завершилась.

$ git commit -a -m 'C# Test with GitHub Actions'
$ git push origin main

Сборка GitHub Actions

Используем секреты в коде тестов

Рассмотрим более сложный пример, когда тест при запуске использует информацию, не предназначенную для публикации в открытом виде, например, токены для доступа в сторонний API. Да, в идеальном мире тесты используют только ненастоящие секреты или секреты, которые не позволяют получить доступ к реальной системе. Но на практике это не всегда так: например, сложные интеграционные или нагрузочные тесты вполне могут требовать доступ к тестируемым стендам, которые могут быть развёрнуты за пределами контура CI.

Хранить такие секреты в открытом виде (особенно в публичных репозиториях) опасно, но такая практика, к сожалению, распространена повсеместно. Для борьбы с этим GitHub даже запустил в 2022 году программу поиска секретов в открытых репозиториях (ранее такая опция была доступна только платным пользователям GitHub Advanced Security), к которой уже подключено более 200 различных типов секретов разнообразных интернет-сервисов. Если секрет становится доступным в одном из репозиториев с кодом, администратор репозитория и владелец секрета получат уведомления об утечке и смогут предпринять необходимые действия для недопущения его использования недоверенными пользователями.

Для примера напишем тест, который формирует JWT для доступа к API Яндекс Облака. Такому тесту необходимы следующие данные для создания токена:

  • идентификатор сервисного аккаунта,
  • идентификатор открытого ключа,
  • закрытый ключ.

Из всех этих данных только закрытый ключ является секретом и не может быть добавлен в открытом виде в репозиторий с нашим кодом. Сначала напишем тест, который будет получать текст закрытого ключа из локального файла, а затем зашифруем его и спрячем ключ шифрования с помощью сервиса GitHub Action Secrets.

Но сперва нам нужно добавить в проект библиотеку для формирования и подписи JWT. Следуя инструкции из документации Яндекс Облака, добавляем в наш проект зависимость на Jose JWT:

$ dotnet add package jose-jwt --version 4.1.0

Создаём в облаке сервисный аккаунт и авторизованный ключ, закрытую часть которого сохраняем в файле sa.key. На самом деле, до того момента как мы соберёмся использовать сформированные JWT для похода в API, нам подойдут абсолютно любые константы в качестве идентификаторов ключа и сервисного аккаунта, а закрытую часть ключа в PEM-формате можно сгенерировать локально с помощью, например, OpenSSL — написанный нами тест всё равно выполнится.

sa.key
-----BEGIN PRIVATE KEY-----
...
-----END PRIVATE KEY-----

Чтобы файл с ключом был доступен коду теста при его запуске, добавим файл проекта секцию с копированием файла в рабочую директорию сборки:

c-sharp-unit-tests-github-actions.csproj
<Project Sdk="Microsoft.NET.Sdk">
...

<ItemGroup>
...
<PackageReference Include="coverlet.collector" Version="3.1.2" />

<None Update="sa.key">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>

</Project>

Добавляем изменённый код теста в проект.

UnitTest1.cs
namespace c_sharp_unit_tests_github_actions;

using System;
using System.Collections.Generic;
using System.IO;
using System.Security.Cryptography;
using Jose;

[TestClass]
public class UnitTest1
{
[TestMethod]
public void TestMethod1()
{
var serviceAccountId = "ajel4hfk79lh6j0ci6aq";
var keyId = "ajeoqmhhondlfj6alhn8";
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();

var headers = new Dictionary<string, object>()
{
{ "kid", keyId }
};

var payload = new Dictionary<string, object>()
{
{ "aud", "https://iam.api.cloud.yandex.net/iam/v1/tokens" },
{ "iss", serviceAccountId },
{ "iat", now },
{ "exp", now + 3600 }
};

using (var rsa = RSA.Create())
{
rsa.ImportFromPem(File.ReadAllText("sa.key").ToCharArray());
string encodedToken = Jose.JWT.Encode(payload, rsa, JwsAlgorithm.PS256, headers);

Assert.IsNotNull(encodedToken);
}
}
}

Снова запускаем тест.

$ dotnet test
Determining projects to restore...
All projects are up-to-date for restore.
c-sharp-unit-tests-github-actions -> /home/user/c-sharp-unit-tests-github-actions/bin/Debug/net7.0/c-sharp-unit-tests-github-actions.dll
Test run for /home/user/c-sharp-unit-tests-github-actions/bin/Debug/net7.0/c-sharp-unit-tests-github-actions.dll (.NETCoreApp,Version=v7.0)
Microsoft (R) Test Execution Command Line Tool Version 17.5.0 (x64)
Copyright (c) Microsoft Corporation. All rights reserved.

Starting test execution, please wait...
A total of 1 test files matched the specified pattern.

Passed! - Failed: 0, Passed: 1, Skipped: 0, Total: 1, Duration: 10 ms - c-sharp-unit-tests-github-actions.dll (net7.0)

Размещаем секреты в GitHub Actions Secrets

GitHub предлагает несколько способов использования секретов при сборках. Можно просто получить доступ к секрету через переменную окружения, а можно хранить в репозитории файлы с зашифрованными секретами, а из переменных окружения получать ключ для их расшифровки. Второй способ кажется мне значительно удобнее и универсальнее, поэтому я решил рассмотреть только его. К тому же размер секрета, который можно получить через переменную окружения, ограничен 48кб, а число таких секретов в репозитории не может превышать 100.

Сначала зашифруем локальный файл с закрытым ключом.

$ gpg --symmetric --cipher-algo AES256 sa.key

После ввода пароля, сохраним его на GitHub в настройках репозитории в секции с секретами с именем SECRETS_PASSPHRASE.

Редактирование секретов GitHub Actions Secrets

Чтобы случайно не закоммитить секрет в репозиторий, добавим его в конец файла .gitignore.

.gitignore
...

# Private keys
*.key

Подготовим файл в корне проекта для расшифровки секретов. У нас он пока один.

decrypt_secrets.sh
!/bin/sh

gpg --quiet --batch --yes --decrypt --passphrase="$SECRETS_PASSPHRASE" --output=sa.key sa.key.gpg

В файл описания сборки добавим шаг с запуском этого скрипта, при этом в переменную окружения SECRETS_PASSPHRASE подставим сохранённое нами в GitHub Actions Secrets значение пароля.

.github/workflows/build-dotnet.yml
name: dotnet test

on: [push]

jobs:
build:
steps:
...

- name: Install dependencies
run: dotnet restore
# Добавляем шаг по расшифровке файлов с секретами перед шагом со сборкой проекта.
- name: Decrypt secrets
run: ./decrypt_secrets.sh
env:
SECRETS_PASSPHRASE: ${{ secrets.SECRETS_PASSPHRASE }}
- name: Build
run: dotnet build --configuration Release --no-restore

Осталось только закоммитить проект и проверить, что сборка работает.

$ git commit -a -m 'C# Test with GitHub Actions Secrets'
$ git push origin main

Сборка GitHub Actions Secrets

Репозиторий с кодом проекта.