A while ago, I wrote a blog post about Azure Functions integration testing with Mountebank and another blog post about end-to-end (E2E) testing for Azure Functions. In the post, I suggested deploying the Azure Functions app first before running the E2E testing. What if you can run the Azure Function app locally within the build pipeline? Then you can get the test result even before the app deployment, which may result in the fail-fast concept.
Throughout this post, I'm going to discuss how to run a function app locally within the build pipeline then run the integration testing scenarios instead of running the E2E testing after the app deployment.
You can find the sample code used in this post at this GitHub repository.
Simple Azure Functions App
Here's the straightforward Azure Function app code. I'm using the Azure Functions OpenAPI extension in this app. It has only one endpoint like below:
public static class DefaultHttpTrigger | |
{ | |
[FunctionName("DefaultHttpTrigger")] | |
[OpenApiOperation(operationId: "greeting", tags: new[] { "greeting" }, Summary = "Greetings", Description = "This shows a welcome message.", Visibility = OpenApiVisibilityType.Important)] | |
[OpenApiSecurity("function_key", SecuritySchemeType.ApiKey, Name = "code", In = OpenApiSecurityLocationType.Query)] | |
[OpenApiParameter("name", Type = typeof(string), In = ParameterLocation.Query, Visibility = OpenApiVisibilityType.Important)] | |
[OpenApiResponseWithBody(statusCode: HttpStatusCode.OK, contentType: "application/json", bodyType: typeof(Greeting), Summary = "The response", Description = "This returns the response")] | |
public static async Task<IActionResult> Run( | |
[HttpTrigger(AuthorizationLevel.Function, "get", Route = "greetings")] HttpRequest req, | |
ILogger log) | |
{ | |
log.LogInformation("C# HTTP trigger function processed a request."); | |
string name = req.Query["name"]; | |
var message = $"Hello, {name}!"; | |
var instance = new Greeting() { Message = message }; | |
var result = new OkObjectResult(instance); | |
return await Task.FromResult(result).ConfigureAwait(false); | |
} | |
} | |
public class Greeting | |
{ | |
public string Message { get; set; } | |
} |
Run this function app and go to the URL, http://localhost:7071/api/openapi/v3.json
, and you will see the following OpenAPI document.
{ | |
"openapi": "3.0.1", | |
"info": { | |
"title": "OpenAPI Document on Azure Functions", | |
"version": "1.0.0" | |
}, | |
"servers": [ | |
{ | |
"url": "http://localhost:7071/api" | |
} | |
], | |
"paths": { | |
"/greetings": { | |
"get": { | |
"tags": [ | |
"greeting" | |
], | |
"summary": "Greetings", | |
"description": "This shows a welcome message.", | |
"operationId": "greeting", | |
"parameters": [ | |
{ | |
"name": "name", | |
"in": "query", | |
"schema": { | |
"type": "string" | |
}, | |
"x-ms-visibility": "important" | |
} | |
], | |
"responses": { | |
"200": { | |
"description": "This returns the response", | |
"content": { | |
"application/json": { | |
"schema": { | |
"$ref": "#/components/schemas/greeting" | |
} | |
} | |
}, | |
"x-ms-summary": "The response" | |
} | |
}, | |
"security": [ | |
{ | |
"function_key": [ ] | |
} | |
], | |
"x-ms-visibility": "important" | |
} | |
} | |
}, | |
"components": { | |
"schemas": { | |
"greeting": { | |
"type": "object", | |
"properties": { | |
"message": { | |
"type": "string" | |
} | |
} | |
} | |
}, | |
"securitySchemes": { | |
"function_key": { | |
"type": "apiKey", | |
"name": "code", | |
"in": "query" | |
} | |
} | |
} | |
} |
At this stage, my focus is to make sure whether the OpenAPI document is correct or not. As you can see the OpenAPI document above, the document has a very simple data structure under the components.schemas.greeting
node. What if the data type is complex? We need confirmation. In this case, should we deploy the app to Azure and run the endpoint over there? Maybe or maybe not.
But this time, let's run the app in the build pipeline and test it there, rather than deploying it to Azure.
Running Azure Functions App as a Background Process
In order to test the Azure Functions app locally, it should be running on the local machine, using the Azure Functions CLI.
func start |
But the issue of this CLI doesn't offer a way to run the app as a background process, something like func start --background
. Therefore, instead of relying on the CLI, we should use the shell command. So, for example, if you use the bash shell, run this func start &
command first, then run bg
.
# Bash | |
func start & | |
bg |
If you use PowerShell, use the Start-Process
cmdlet with the -NoNewWindow
switch so that the function app runs as a background process.
# PowerShell | |
Start-Process -NoNewWindow func start |
Once the function app is running, execute this bash command on the same console session, curl http://localhost:7071/api/openapi/v3.json
. Alternatively, use the PowerShell cmdlet Invoke-RestMethod -Method Get -Uri http://localhost:7071/api/openapi/v3.json
to get the OpenAPI document.
Writing Integration Test Codes
As we've got the function app running in the background, we can now write the test codes on top of that. So let's have a look at the code below. First, it sends a GET request to the endpoint, http://localhost:7071/api/openapi/v3.json
, gets the response as a string and deserialises it to OpenApiDocument
, and finally asserts the results whether it's expected or not.
[TestClass] | |
public class DefaultHttpTriggerTests | |
{ | |
private HttpClient _http; | |
[TestInitialize] | |
public void Initialize() | |
{ | |
this._http = new HttpClient(); | |
} | |
[TestCleanup] | |
public void Cleanup() | |
{ | |
this._http.Dispose(); | |
} | |
[TestMethod] | |
public async Task Given_OpenApiUrl_When_Endpoint_Invoked_Then_It_Should_Return_Title() | |
{ | |
// Arrange | |
var requestUri = "http://localhost:7071/api/openapi/v3.json"; | |
// Act | |
var response = await this._http.GetStringAsync(requestUri).ConfigureAwait(false); | |
var doc = JsonConvert.DeserializeObject<OpenApiDocument>(response); | |
// Assert | |
doc.Should().NotBeNull(); | |
doc.Info.Title.Should().Be("OpenAPI Document on Azure Functions"); | |
doc.Components.Schemas.Should().ContainKey("greeting"); | |
var schema = doc.Components.Schemas["greeting"]; | |
schema.Type.Should().Be("object"); | |
schema.Properties.Should().ContainKey("message"); | |
var property = schema.Properties["message"]; | |
property.Type.Should().Be("string"); | |
} | |
} |
As you can see from the test codes above, there's no mocking. Instead, we just use the actual endpoint running on the local machine.
Once the test codes are ready, run the following command:
dotnet test |
You will get the test results.
Putting Altogether to GitHub Actions
We knew how to run the function app as a background process and got the test codes. Our CI/CD pipeline should be able to execute this. Let's have a look at the GitHub Actions workflow. Some actions are omitted for brevity.
GitHub-hosted Runners
It really depends on the situation, but I'm assuming we should test the code on all the operating systems – Windows, Mac and Linux. In this case, use the matrix
attribute.
jobs: | |
build_and_test: | |
name: Build and test | |
strategy: | |
matrix: | |
os: [ 'windows-latest', 'macos-latest', 'ubuntu-latest' ] | |
runs-on: ${{ matrix.os }} |
GitHub-hosted runners don't have the Azure Functions CLI installed by default. Therefore, you should install it by yourself.
steps: | |
- name: Checkout the repository | |
uses: actions/checkout@v2 | |
- name: Setup Azure Functions Core Tools | |
shell: pwsh | |
run: | | |
npm install -g azure-functions-core-tools@3 --unsafe-perm true |
Install the .NET Core 3.1 SDK as well.
- name: Setup .NET SDK 3.1 LTS | |
uses: actions/setup-dotnet@v1 | |
with: | |
dotnet-version: '3.1.x' |
After all the tools are installed, testing should be followed.
The following action is for Mac and Linux runners. The first step is to run the function app as a background process. Although you can simply use the command func start
, this action declares func @("start","--verbose","false")
to minimise the noise from the local debugging log messages.
- name: Test function app (Non-Windows) | |
if: matrix.os != 'windows-latest' | |
shell: pwsh | |
run: | | |
dir | |
$rootDir = $pwd.Path | |
cd ./src/FunctionApp | |
Start-Process -NoNewWindow func @("start","--verbose","false") | |
Start-Sleep -s 60 | |
cd $rootDir/test/FunctionApp.Tests | |
dotnet test . -c Debug | |
cd $rootDir |
On the other hand, the following action is for Windows runner. The main difference from the other action is that this time doesn't use the func
command but the $func
variable. Using the func
command will get the error like This command cannot be run due to the error: %1 is not a valid Win32 application.
. It's because the func
command points to the func.ps1
file, which is the PowerShell script. Instead of using this PowerShell script, you need to call func.cmd
to run the function app as a background process.
- name: Test function app (Windows) | |
if: matrix.os == 'windows-latest' | |
shell: pwsh | |
run: | | |
dir | |
$rootDir = $pwd.Path | |
$func = $(Get-Command func).Source.Replace(".ps1", ".cmd") | |
cd ./src/FunctionApp | |
Start-Process -NoNewWindow "$func" @("start","--verbose","false") | |
Start-Sleep -s 60 | |
cd $rootDir/test/FunctionApp.Tests | |
dotnet test . -c Debug | |
cd $rootDir |
Once all the GitHub Actions workflow is set, push your codes to GitHub. Then you'll see all the build pipeline works as expected.
So far, we've walked through how to run the integration testing codes for Azure Functions app within the GitHub Actions workflow, using Azure Functions CLI as a background process. As a result, you can now avoid extra steps for the app deployment to Azure for testing.