I wanted to have some integration tests for Azure Functions, especially for some complex durable functions. When you have durable functions and when you want to make
sure that the orchestrations are behaving as expected, having integration
tests is the only way to ensure that. And another important thing is I needed to be able to run these tests not just locally, but in a CI pipeline (GitHub
workflows, Azure DevOps Pipeline, etc) as well.
Unfortunately as of today, there is no proper integration test mechanism for
Azure Durable Functions (or Azure Functions) like we have for
ASP.NET Core applications.
I came up with the following approach after gathering inputs from GitHub
issues and other related posts on the subject.
The basic concept is as follows. Note I am using
XUnit.net as my testing
Framework.
1. Create a fixture class that implements
IDisposable
and on the constructor, I am spinning up the Function Application to test
using func start. And doing the cleanup on
Dispose().
2. Create an XUnit Collection Fixture using the above fixture. So basically my
single test context (the function application) will get shared among different
tests in several test classes, and it will get cleaned up after all the tests
in the test classes have finished.
My fixture looks like something below.
using Polly;
using System.Diagnostics;
using System.Runtime.InteropServices;
namespace HelloAzureFunctions.Tests.Integration.Fixtures;
public class AzureFunctionFixture : IDisposable
{
private readonly string _path = Directory.GetCurrentDirectory();
private readonly string _testOutputPath = Path.Combine(Directory.GetCurrentDirectory(), "integration-test-output.log");
private readonly int _port = 7071;
private readonly string _baseUrl;
private readonly Process _process;
public readonly HttpClient HttpClient;
public AzureFunctionFixture()
{
_baseUrl = $"http://localhost:{_port}";
HttpClient = new HttpClient()
{
BaseAddress = new Uri(_baseUrl)
};
if (File.Exists(_testOutputPath))
{
File.Delete(_testOutputPath);
}
DirectoryInfo directoryInfo = new(_path);
_process = StartProcess(_port, directoryInfo);
_process.OutputDataReceived += (sender, args) =>
{
File.AppendAllLines(_testOutputPath, [args.Data]);
};
_process.BeginOutputReadLine();
}
public void Dispose()
{
if (!_process.HasExited)
{
_process.Kill(entireProcessTree: true);
}
_process.Dispose();
HttpClient.Dispose();
}
public async Task WaitUntilFunctionsAreRunning()
{
PolicyResult<HttpResponseMessage> result =
await Policy.TimeoutAsync(TimeSpan.FromSeconds(30))
.WrapAsync(Policy.Handle<Exception>().WaitAndRetryForeverAsync(index => TimeSpan.FromMilliseconds(500)))
.ExecuteAndCaptureAsync(() => HttpClient.GetAsync(""));
if (result.Outcome != OutcomeType.Successful)
{
throw new InvalidOperationException("The Azure Functions project doesn't seem to be running.");
}
}
private static Process StartProcess(int port, DirectoryInfo workingDirectory)
{
string fileName = "func";
string arguments = $"start --port {port} --verbose";
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
fileName = "powershell.exe";
arguments = $"func start --port {port} --verbose";
}
ProcessStartInfo processStartInfo = new(fileName, arguments)
{
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
WorkingDirectory = workingDirectory.FullName,
EnvironmentVariables =
{
// Passing an additional environment variable to the application,
// So it can control the behavior when running for Integration Tests
[ApplicationConstants.IsRunningIntegrationTests] = "true"
}
};
Process process = new() { StartInfo = processStartInfo };
process.Start();
return process;
}
}
I can use this fixture for my tests and it will work fine for running
integration tests locally.
Now we need to be able to run these tests in a CI pipeline. I am using the
following GitHub workflow.
name: Run Integration Tests
on:
push:
branches: ["main"]
paths-ignore:
- '**.md'
env:
DOTNET_VERSION: '8.0.x'
jobs:
build-and-test:
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
env:
INTEGRATION_TEST_EXECUTION_DIRECTORY: ./tests/HelloAzureFunctions.Tests.Integration/bin/Debug/net8.0
steps:
- name: 'Checkout GitHub Action'
uses: actions/checkout@v3
- name: Setup .NET ${{ env.DOTNET_VERSION }} Environment
uses: actions/setup-dotnet@v3
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Build
run: dotnet build
# Install Azure Functions Core Tools in the runner,
# so we have access to 'func.exe' to spin up the Azure Functions app in integration tests
- name: Install Azure Functions Core Tools
run: |
npm install -g azure-functions-core-tools@4 --unsafe-perm true
# Setup Azurite in the runner,
# so the Azure Functions app we are going to spin up, can use azurite as it's Storage Provider
- name: Setup Azurite
shell: bash
run: |
npm install -g azurite
azurite --silent &
- name: Run Integration Tests
# If there are any errors executing integration tests, uncomment the following line to continue the workflow, so you can look at integration-test-output.log
# continue-on-error: true
run: dotnet test ${{ env.INTEGRATION_TEST_EXECUTION_DIRECTORY }}/HelloAzureFunctions.Tests.Integration.dll
- name: Upload Integration Tests Execution Log
uses: actions/upload-artifact@v4
with:
name: artifact-${{ matrix.os }}
path: ${{ env.INTEGRATION_TEST_EXECUTION_DIRECTORY }}/integration-test-output.log
When the workflow runs, the output is as follows.
You can find the full sample code here on this repo:
Happy Coding.
Regards,
Jaliya
No comments:
Post a Comment