Skip to content

Commit

Permalink
Introduce integration testing / spec-first libs with initial support for
Browse files Browse the repository at this point in the history
xunit
  • Loading branch information
rmorris authored and domaindrivendev committed Mar 15, 2019
1 parent 4caddac commit ed89d69
Show file tree
Hide file tree
Showing 45 changed files with 3,370 additions and 1 deletion.
77 changes: 76 additions & 1 deletion Swashbuckle.AspNetCore.sln
@@ -1,4 +1,4 @@
Microsoft Visual Studio Solution File, Format Version 12.00
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio 15
VisualStudioVersion = 15.0.27004.2006
MinimumVisualStudioVersion = 10.0.40219.1
Expand Down Expand Up @@ -67,6 +67,16 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OAuth2Integration", "test\W
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ConfigFromFile", "test\WebSites\ConfigFromFile\ConfigFromFile.csproj", "{221F533C-BCB5-4742-ACE3-25561D545EA4}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestFirst", "test\WebSites\TestFirst\TestFirst.csproj", "{B69B8131-7F3B-4872-8C6E-B18EA82A138B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestFirst.IntegrationTests", "test\WebSites\TestFirst.IntegrationTests\TestFirst.IntegrationTests.csproj", "{6A520C89-4048-43AD-B3B4-B3ED75C9297B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Swashbuckle.AspNetCore.ApiTesting", "src\Swashbuckle.AspNetCore.ApiTesting\Swashbuckle.AspNetCore.ApiTesting.csproj", "{E77079C1-51C1-47F1-A841-B4BF040EFFA0}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Swashbuckle.AspNetCore.ApiTesting.Xunit", "src\Swashbuckle.AspNetCore.ApiTesting.Xunit\Swashbuckle.AspNetCore.ApiTesting.Xunit.csproj", "{756A46AA-E577-4500-9FBA-D3D406811DB5}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Swashbuckle.AspNetCore.ApiTesting.Test", "test\Swashbuckle.AspNetCore.ApiTesting.Test\Swashbuckle.AspNetCore.ApiTesting.Test.csproj", "{79A1A89C-11D0-4976-BFFB-78B0A2998666}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -317,6 +327,66 @@ Global
{221F533C-BCB5-4742-ACE3-25561D545EA4}.Release|x64.Build.0 = Release|Any CPU
{221F533C-BCB5-4742-ACE3-25561D545EA4}.Release|x86.ActiveCfg = Release|Any CPU
{221F533C-BCB5-4742-ACE3-25561D545EA4}.Release|x86.Build.0 = Release|Any CPU
{B69B8131-7F3B-4872-8C6E-B18EA82A138B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B69B8131-7F3B-4872-8C6E-B18EA82A138B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B69B8131-7F3B-4872-8C6E-B18EA82A138B}.Debug|x64.ActiveCfg = Debug|Any CPU
{B69B8131-7F3B-4872-8C6E-B18EA82A138B}.Debug|x64.Build.0 = Debug|Any CPU
{B69B8131-7F3B-4872-8C6E-B18EA82A138B}.Debug|x86.ActiveCfg = Debug|Any CPU
{B69B8131-7F3B-4872-8C6E-B18EA82A138B}.Debug|x86.Build.0 = Debug|Any CPU
{B69B8131-7F3B-4872-8C6E-B18EA82A138B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B69B8131-7F3B-4872-8C6E-B18EA82A138B}.Release|Any CPU.Build.0 = Release|Any CPU
{B69B8131-7F3B-4872-8C6E-B18EA82A138B}.Release|x64.ActiveCfg = Release|Any CPU
{B69B8131-7F3B-4872-8C6E-B18EA82A138B}.Release|x64.Build.0 = Release|Any CPU
{B69B8131-7F3B-4872-8C6E-B18EA82A138B}.Release|x86.ActiveCfg = Release|Any CPU
{B69B8131-7F3B-4872-8C6E-B18EA82A138B}.Release|x86.Build.0 = Release|Any CPU
{6A520C89-4048-43AD-B3B4-B3ED75C9297B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6A520C89-4048-43AD-B3B4-B3ED75C9297B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6A520C89-4048-43AD-B3B4-B3ED75C9297B}.Debug|x64.ActiveCfg = Debug|Any CPU
{6A520C89-4048-43AD-B3B4-B3ED75C9297B}.Debug|x64.Build.0 = Debug|Any CPU
{6A520C89-4048-43AD-B3B4-B3ED75C9297B}.Debug|x86.ActiveCfg = Debug|Any CPU
{6A520C89-4048-43AD-B3B4-B3ED75C9297B}.Debug|x86.Build.0 = Debug|Any CPU
{6A520C89-4048-43AD-B3B4-B3ED75C9297B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6A520C89-4048-43AD-B3B4-B3ED75C9297B}.Release|Any CPU.Build.0 = Release|Any CPU
{6A520C89-4048-43AD-B3B4-B3ED75C9297B}.Release|x64.ActiveCfg = Release|Any CPU
{6A520C89-4048-43AD-B3B4-B3ED75C9297B}.Release|x64.Build.0 = Release|Any CPU
{6A520C89-4048-43AD-B3B4-B3ED75C9297B}.Release|x86.ActiveCfg = Release|Any CPU
{6A520C89-4048-43AD-B3B4-B3ED75C9297B}.Release|x86.Build.0 = Release|Any CPU
{E77079C1-51C1-47F1-A841-B4BF040EFFA0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E77079C1-51C1-47F1-A841-B4BF040EFFA0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E77079C1-51C1-47F1-A841-B4BF040EFFA0}.Debug|x64.ActiveCfg = Debug|Any CPU
{E77079C1-51C1-47F1-A841-B4BF040EFFA0}.Debug|x64.Build.0 = Debug|Any CPU
{E77079C1-51C1-47F1-A841-B4BF040EFFA0}.Debug|x86.ActiveCfg = Debug|Any CPU
{E77079C1-51C1-47F1-A841-B4BF040EFFA0}.Debug|x86.Build.0 = Debug|Any CPU
{E77079C1-51C1-47F1-A841-B4BF040EFFA0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E77079C1-51C1-47F1-A841-B4BF040EFFA0}.Release|Any CPU.Build.0 = Release|Any CPU
{E77079C1-51C1-47F1-A841-B4BF040EFFA0}.Release|x64.ActiveCfg = Release|Any CPU
{E77079C1-51C1-47F1-A841-B4BF040EFFA0}.Release|x64.Build.0 = Release|Any CPU
{E77079C1-51C1-47F1-A841-B4BF040EFFA0}.Release|x86.ActiveCfg = Release|Any CPU
{E77079C1-51C1-47F1-A841-B4BF040EFFA0}.Release|x86.Build.0 = Release|Any CPU
{756A46AA-E577-4500-9FBA-D3D406811DB5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{756A46AA-E577-4500-9FBA-D3D406811DB5}.Debug|Any CPU.Build.0 = Debug|Any CPU
{756A46AA-E577-4500-9FBA-D3D406811DB5}.Debug|x64.ActiveCfg = Debug|Any CPU
{756A46AA-E577-4500-9FBA-D3D406811DB5}.Debug|x64.Build.0 = Debug|Any CPU
{756A46AA-E577-4500-9FBA-D3D406811DB5}.Debug|x86.ActiveCfg = Debug|Any CPU
{756A46AA-E577-4500-9FBA-D3D406811DB5}.Debug|x86.Build.0 = Debug|Any CPU
{756A46AA-E577-4500-9FBA-D3D406811DB5}.Release|Any CPU.ActiveCfg = Release|Any CPU
{756A46AA-E577-4500-9FBA-D3D406811DB5}.Release|Any CPU.Build.0 = Release|Any CPU
{756A46AA-E577-4500-9FBA-D3D406811DB5}.Release|x64.ActiveCfg = Release|Any CPU
{756A46AA-E577-4500-9FBA-D3D406811DB5}.Release|x64.Build.0 = Release|Any CPU
{756A46AA-E577-4500-9FBA-D3D406811DB5}.Release|x86.ActiveCfg = Release|Any CPU
{756A46AA-E577-4500-9FBA-D3D406811DB5}.Release|x86.Build.0 = Release|Any CPU
{79A1A89C-11D0-4976-BFFB-78B0A2998666}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{79A1A89C-11D0-4976-BFFB-78B0A2998666}.Debug|Any CPU.Build.0 = Debug|Any CPU
{79A1A89C-11D0-4976-BFFB-78B0A2998666}.Debug|x64.ActiveCfg = Debug|Any CPU
{79A1A89C-11D0-4976-BFFB-78B0A2998666}.Debug|x64.Build.0 = Debug|Any CPU
{79A1A89C-11D0-4976-BFFB-78B0A2998666}.Debug|x86.ActiveCfg = Debug|Any CPU
{79A1A89C-11D0-4976-BFFB-78B0A2998666}.Debug|x86.Build.0 = Debug|Any CPU
{79A1A89C-11D0-4976-BFFB-78B0A2998666}.Release|Any CPU.ActiveCfg = Release|Any CPU
{79A1A89C-11D0-4976-BFFB-78B0A2998666}.Release|Any CPU.Build.0 = Release|Any CPU
{79A1A89C-11D0-4976-BFFB-78B0A2998666}.Release|x64.ActiveCfg = Release|Any CPU
{79A1A89C-11D0-4976-BFFB-78B0A2998666}.Release|x64.Build.0 = Release|Any CPU
{79A1A89C-11D0-4976-BFFB-78B0A2998666}.Release|x86.ActiveCfg = Release|Any CPU
{79A1A89C-11D0-4976-BFFB-78B0A2998666}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand All @@ -343,6 +413,11 @@ Global
{47CB5DDC-D02E-4F39-B4B2-4716F094C597} = {1669F896-133C-4996-B58C-E7CDA299ADFF}
{A0162363-B43F-4243-8A20-8064B161C190} = {245144DE-BC89-4822-B044-020458BFECC0}
{221F533C-BCB5-4742-ACE3-25561D545EA4} = {245144DE-BC89-4822-B044-020458BFECC0}
{B69B8131-7F3B-4872-8C6E-B18EA82A138B} = {245144DE-BC89-4822-B044-020458BFECC0}
{6A520C89-4048-43AD-B3B4-B3ED75C9297B} = {245144DE-BC89-4822-B044-020458BFECC0}
{E77079C1-51C1-47F1-A841-B4BF040EFFA0} = {15A55F4A-FC33-4D96-BAAD-FBDCDD96D5F5}
{756A46AA-E577-4500-9FBA-D3D406811DB5} = {15A55F4A-FC33-4D96-BAAD-FBDCDD96D5F5}
{79A1A89C-11D0-4976-BFFB-78B0A2998666} = {1669F896-133C-4996-B58C-E7CDA299ADFF}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {36FC6A67-247D-4149-8EDD-79FFD1A75F51}
Expand Down
42 changes: 42 additions & 0 deletions src/Swashbuckle.AspNetCore.ApiTesting.Xunit/ApiTestFixture.cs
@@ -0,0 +1,42 @@
using System.Net.Http;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.OpenApi.Models;
using Xunit;

namespace Swashbuckle.AspNetCore.ApiTesting.Xunit
{
[Collection("ApiTests")]
public class ApiTestFixture<TEntryPoint> :
IClassFixture<WebApplicationFactory<TEntryPoint>> where TEntryPoint : class
{
private readonly ApiTestRunnerBase _apiTestRunner;
private readonly WebApplicationFactory<TEntryPoint> _webAppFactory;
private readonly string _documentName;

public ApiTestFixture(
ApiTestRunnerBase apiTestRunner,
WebApplicationFactory<TEntryPoint> webAppFactory,
string documentName)
{
_apiTestRunner = apiTestRunner;
_webAppFactory = webAppFactory;
_documentName = documentName;
}

public void Describe(string pathTemplate, OperationType operationType, OpenApiOperation operationSpec)
{
_apiTestRunner.ConfigureOperation(_documentName, pathTemplate, operationType, operationSpec);
}

public async Task TestAsync(string operationId, string expectedStatusCode, HttpRequestMessage request)
{
await _apiTestRunner.TestAsync(
_documentName,
operationId,
expectedStatusCode,
request,
_webAppFactory.CreateClient());
}
}
}
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<Description>Xunit add-on for Swagger/OpenAPI-driven integration testing API's built on ASP.NET Core</Description>
<TargetFramework>netcoreapp2.1</TargetFramework>
<NoWarn>$(NoWarn);1591</NoWarn>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<AssemblyName>Swashbuckle.AspNetCore.ApiTesting.Xunit</AssemblyName>
<PackageId>Swashbuckle.AspNetCore.ApiTesting.Xunit</PackageId>
<PackageTags>swagger;openapi;test-first;spec-first;testing;aspnetcore;xunit</PackageTags>
<PackageProjectUrl>https://github.com/domaindrivendev/Swashbuckle.AspNetCore</PackageProjectUrl>
<PackageLicenseUrl>https://raw.githubusercontent.com/domaindrivendev/Swashbuckle.AspNetCore/master/LICENSE</PackageLicenseUrl>
<RepositoryType>git</RepositoryType>
<RepositoryUrl>https://github.com/domaindrivendev/Swashbuckle.AspNetCore.git</RepositoryUrl>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\Swashbuckle.AspNetCore.ApiTesting\Swashbuckle.AspNetCore.ApiTesting.csproj" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="xunit" Version="2.2.0" />
</ItemGroup>
</Project>
88 changes: 88 additions & 0 deletions src/Swashbuckle.AspNetCore.ApiTesting/ApiTestRunnerBase.cs
@@ -0,0 +1,88 @@
using System;
using System.IO;
using System.Net.Http;
using System.Threading.Tasks;
using Microsoft.OpenApi.Models;
using Microsoft.OpenApi.Writers;

namespace Swashbuckle.AspNetCore.ApiTesting
{
public abstract class ApiTestRunnerBase : IDisposable
{
private readonly ApiTestRunnerOptions _options;
private readonly RequestValidator _requestValidator;
private readonly ResponseValidator _responseValidator;

protected ApiTestRunnerBase()
{
_options = new ApiTestRunnerOptions();
_requestValidator = new RequestValidator(_options.ContentValidators);
_responseValidator = new ResponseValidator(_options.ContentValidators);
}

public void Configure(Action<ApiTestRunnerOptions> setupAction)
{
setupAction(_options);
}

public void ConfigureOperation(
string documentName,
string pathTemplate,
OperationType operationType,
OpenApiOperation operation)
{
var openApiDocument = _options.GetOpenApiDocument(documentName);

if (openApiDocument.Paths == null)
openApiDocument.Paths = new OpenApiPaths();

if (!openApiDocument.Paths.TryGetValue(pathTemplate, out OpenApiPathItem pathItem))
{
pathItem = new OpenApiPathItem();
openApiDocument.Paths.Add(pathTemplate, pathItem);
}

pathItem.AddOperation(operationType, operation);
}

public async Task TestAsync(
string documentName,
string operationId,
string expectedStatusCode,
HttpRequestMessage request,
HttpClient httpClient)
{
var openApiDocument = _options.GetOpenApiDocument(documentName);
if (!openApiDocument.TryFindOperationById(operationId, out string pathTemplate, out OperationType operationType))
throw new InvalidOperationException($"Operation with id '{operationId}' not found in OpenAPI document '{documentName}'");

if (expectedStatusCode.StartsWith("2"))
_requestValidator.Validate(request, openApiDocument, pathTemplate, operationType);

var response = await httpClient.SendAsync(request);

_responseValidator.Validate(response, openApiDocument, pathTemplate, operationType, expectedStatusCode);
}

public void Dispose()
{
if (!_options.GenerateOpenApiFiles) return;

if (_options.FileOutputRoot == null)
throw new Exception("GenerateOpenApiFiles set but FileOutputRoot is null");

foreach (var entry in _options.OpenApiDocs)
{
var outputDir = Path.Combine(_options.FileOutputRoot, entry.Key);
Directory.CreateDirectory(outputDir);

using (var streamWriter = new StreamWriter(Path.Combine(outputDir, "openapi.json")))
{
var openApiJsonWriter = new OpenApiJsonWriter(streamWriter);
entry.Value.SerializeAsV3(openApiJsonWriter);
streamWriter.Close();
}
}
}
}
}
24 changes: 24 additions & 0 deletions src/Swashbuckle.AspNetCore.ApiTesting/ApiTestRunnerOptions.cs
@@ -0,0 +1,24 @@
using System.Collections.Generic;
using Microsoft.OpenApi.Models;

namespace Swashbuckle.AspNetCore.ApiTesting
{
public class ApiTestRunnerOptions
{
public ApiTestRunnerOptions()
{
OpenApiDocs = new Dictionary<string, OpenApiDocument>();
ContentValidators = new List<IContentValidator> { new JsonContentValidator() };
GenerateOpenApiFiles = false;
FileOutputRoot = null;
}

public Dictionary<string, OpenApiDocument> OpenApiDocs { get; }

public List<IContentValidator> ContentValidators { get; }

public bool GenerateOpenApiFiles { get; set; }

public string FileOutputRoot { get; set; }
}
}
@@ -0,0 +1,27 @@
using Microsoft.OpenApi.Models;
using Microsoft.OpenApi.Readers;
using System;
using System.IO;

namespace Swashbuckle.AspNetCore.ApiTesting
{
public static class ApiTestRunnerOptionsExtensions
{
public static void AddOpenApiFile(this ApiTestRunnerOptions options, string documentName, string filePath)
{
using (var fileStream = File.OpenRead(filePath))
{
var openApiDocument = new OpenApiStreamReader().Read(fileStream, out OpenApiDiagnostic diagnostic);
options.OpenApiDocs.Add(documentName, openApiDocument);
}
}

public static OpenApiDocument GetOpenApiDocument(this ApiTestRunnerOptions options, string documentName)
{
if (!options.OpenApiDocs.TryGetValue(documentName, out OpenApiDocument document))
throw new InvalidOperationException($"Document with name '{documentName}' not found");

return document;
}
}
}
18 changes: 18 additions & 0 deletions src/Swashbuckle.AspNetCore.ApiTesting/HttpHeadersExtensions.cs
@@ -0,0 +1,18 @@
using System.Collections.Specialized;
using System.Net.Http.Headers;

namespace Swashbuckle.AspNetCore.ApiTesting
{
public static class HttpHeadersExtensions
{
internal static NameValueCollection ToNameValueCollection(this HttpHeaders httpHeaders)
{
var headerNameValues = new NameValueCollection();
foreach (var entry in httpHeaders)
{
headerNameValues.Add(entry.Key, string.Join(',', entry.Value));
}
return headerNameValues;
}
}
}
20 changes: 20 additions & 0 deletions src/Swashbuckle.AspNetCore.ApiTesting/IContentValidator.cs
@@ -0,0 +1,20 @@
using System;
using System.Net.Http;
using Microsoft.OpenApi.Models;

namespace Swashbuckle.AspNetCore.ApiTesting
{
public interface IContentValidator
{
bool CanValidate(string mediaType);

void Validate(OpenApiMediaType mediaTypeSpec, OpenApiDocument openApiDocument, HttpContent content);
}

public class ContentDoesNotMatchSpecException : Exception
{
public ContentDoesNotMatchSpecException(string message)
: base(message)
{ }
}
}
32 changes: 32 additions & 0 deletions src/Swashbuckle.AspNetCore.ApiTesting/JsonContentValidator.cs
@@ -0,0 +1,32 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using Microsoft.OpenApi.Models;
using Newtonsoft.Json.Linq;

namespace Swashbuckle.AspNetCore.ApiTesting
{
public class JsonContentValidator : IContentValidator
{
private readonly JsonValidator _jsonValidator;

public JsonContentValidator()
{
_jsonValidator = new JsonValidator();
}

public bool CanValidate(string mediaType)
{
return mediaType.Contains("json");
}

public void Validate(OpenApiMediaType mediaTypeSpec, OpenApiDocument openApiDocument, HttpContent content)
{
if (mediaTypeSpec?.Schema == null) return;

var instance = JToken.Parse(content.ReadAsStringAsync().Result);
if (!_jsonValidator.Validate(mediaTypeSpec.Schema, openApiDocument, instance, out IEnumerable<string> errorMessages))
throw new ContentDoesNotMatchSpecException(string.Join(Environment.NewLine, errorMessages));
}
}
}

0 comments on commit ed89d69

Please sign in to comment.