Testing Guide - Sky.Cms.Api.Shared¶
Summary¶
Testing strategies for Sky.Cms.Api.Shared — unit tests with Moq, integration tests with TestServer, and recommended project structure.
Overview¶
Recommended project structure for test files, plus unit and integration test patterns using Moq and TestServer.
Test Structure¶
The test project (AspNetCore.Identity.FlexDb.Tests or similar) should follow this structure:
Tests/
├── Features/
│ └── ContactForm/
│ ├── Submit/
│ │ └── SubmitContactFormHandlerTests.cs
│ └── ValidateCaptcha/
│ └── ValidateCaptchaHandlerTests.cs
├── Services/
│ ├── ContactServiceTests.cs
│ └── CaptchaValidatorTests.cs
├── Controllers/
│ └── ContactApiControllerTests.cs
└── TestHelpers/
├── TestDataBuilder.cs
└── MockFactories.cs
Unit Testing¶
Testing a Handler¶
Example: SubmitContactFormHandler¶
namespace Sky.Cms.Api.Shared.Tests.Features.ContactForm.Submit;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
using Sky.Cms.Api.Shared.Features.ContactForm.Submit;
using Sky.Cms.Api.Shared.Models;
using Microsoft.Extensions.Logging;
using Cosmos.EmailServices;
using Microsoft.Extensions.Options;
[TestClass]
public class SubmitContactFormHandlerTests
{
private Mock<ICosmosEmailSender> emailSenderMock;
private Mock<ILogger<SubmitContactFormHandler>> loggerMock;
private SubmitContactFormHandler handler;
private ContactApiConfig config;
[TestInitialize]
public void Setup()
{
emailSenderMock = new Mock<ICosmosEmailSender>();
loggerMock = new Mock<ILogger<SubmitContactFormHandler>>();
config = new ContactApiConfig
{
AdminEmail = "[email protected]",
MaxMessageLength = 5000,
RequireCaptcha = false
};
var optionsMock = Options.Create(config);
handler = new SubmitContactFormHandler(emailSenderMock.Object, loggerMock.Object, optionsMock);
}
[TestMethod]
public async Task HandleAsync_WithValidRequest_SendsEmail()
{
// Arrange
var request = new ContactFormRequest
{
Name = "John Doe",
Email = "[email protected]",
Message = "This is a test message that is longer than 10 characters"
};
var command = new SubmitContactFormCommand(request);
// Mock successful email send
var mockResult = new Mock<IHttpResponseMessage>();
mockResult.Setup(r => r.IsSuccessStatusCode).Returns(true);
emailSenderMock.Setup(e => e.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync(mockResult.Object);
// Act
var result = await handler.HandleAsync(command);
// Assert
Assert.IsTrue(result.Success);
Assert.AreEqual("Thank you for your message. We'll get back to you soon!", result.Message);
// Verify email was sent
emailSenderMock.Verify(
e => e.SendEmailAsync(
config.AdminEmail,
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<string>(),
request.Email),
Times.Once);
}
[TestMethod]
public async Task HandleAsync_WithEmailSendFailure_ReturnsFailureResponse()
{
// Arrange
var request = new ContactFormRequest
{
Name = "Jane Smith",
Email = "[email protected]",
Message = "This is another test message for failure scenario"
};
var command = new SubmitContactFormCommand(request);
// Mock failed email send
var mockResult = new Mock<IHttpResponseMessage>();
mockResult.Setup(r => r.IsSuccessStatusCode).Returns(false);
mockResult.Setup(r => r.StatusCode).Returns(System.Net.HttpStatusCode.InternalServerError);
emailSenderMock.Setup(e => e.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync(mockResult.Object);
// Act
var result = await handler.HandleAsync(command);
// Assert
Assert.IsFalse(result.Success);
Assert.AreEqual("Email delivery failed", result.Error);
}
[TestMethod]
[ExpectedException(typeof(ArgumentNullException))]
public void Constructor_WithNullEmailSender_ThrowsException()
{
// Should throw ArgumentNullException
var handler = new SubmitContactFormHandler(null, loggerMock.Object, Options.Create(config));
}
}
Testing a Service¶
Example: ContactService CAPTCHA Validation¶
namespace Sky.Cms.Api.Shared.Tests.Services;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
using Sky.Cms.Api.Shared.Services;
using Sky.Cms.Api.Shared.Models;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Net;
using System.Net.Http;
[TestClass]
public class ContactServiceCaptchaTests
{
private Mock<IHttpClientFactory> httpClientFactoryMock;
private Mock<ICosmosEmailSender> emailSenderMock;
private Mock<ILogger<ContactService>> loggerMock;
private ContactService service;
[TestInitialize]
public void Setup()
{
httpClientFactoryMock = new Mock<IHttpClientFactory>();
emailSenderMock = new Mock<ICosmosEmailSender>();
loggerMock = new Mock<ILogger<ContactService>>();
}
private void CreateService(ContactApiConfig config)
{
var optionsMock = Options.Create(config);
service = new ContactService(httpClientFactoryMock.Object, emailSenderMock.Object, loggerMock.Object, optionsMock);
}
[TestMethod]
public async Task ValidateCaptchaAsync_WithDisabledCaptcha_ReturnsTrue()
{
// Arrange
var config = new ContactApiConfig { RequireCaptcha = false };
CreateService(config);
// Act
var result = await service.ValidateCaptchaAsync("any-token", "192.168.1.1");
// Assert
Assert.IsTrue(result);
}
[TestMethod]
public async Task ValidateCaptchaAsync_WithTurnstileSuccess_ReturnsTrue()
{
// Arrange
var config = new ContactApiConfig
{
RequireCaptcha = true,
CaptchaProvider = "turnstile",
CaptchaSecretKey = "test-secret"
};
CreateService(config);
var mockHttpClient = new Mock<HttpClient>();
var mockResponse = new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent(@"{ ""success"": true }")
};
httpClientFactoryMock.Setup(f => f.CreateClient())
.Returns(mockHttpClient.Object);
// Act & Assert - Implementation details would follow
}
[TestMethod]
public async Task ValidateCaptchaAsync_WithTurnstileFailure_ReturnsFalse()
{
// Arrange
var config = new ContactApiConfig
{
RequireCaptcha = true,
CaptchaProvider = "turnstile",
CaptchaSecretKey = "test-secret"
};
CreateService(config);
// Act & Assert - Implementation details would follow
}
}
Model Validation Tests¶
namespace Sky.Cms.Api.Shared.Tests.Models;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System.ComponentModel.DataAnnotations;
using Sky.Cms.Api.Shared.Models;
[TestClass]
public class ContactFormRequestValidationTests
{
private ContactFormRequest request;
[TestInitialize]
public void Setup()
{
request = new ContactFormRequest
{
Name = "John Doe",
Email = "[email protected]",
Message = "This is a valid test message"
};
}
[TestMethod]
public void ValidRequest_PassesValidation()
{
// Arrange & Act
var validationContext = new ValidationContext(request);
var results = new List<ValidationResult>();
var isValid = Validator.TryValidateObject(request, validationContext, results, true);
// Assert
Assert.IsTrue(isValid);
Assert.AreEqual(0, results.Count);
}
[TestMethod]
public void EmptyName_FailsValidation()
{
// Arrange
request.Name = string.Empty;
// Act
var validationContext = new ValidationContext(request);
var results = new List<ValidationResult>();
var isValid = Validator.TryValidateObject(request, validationContext, results, true);
// Assert
Assert.IsFalse(isValid);
Assert.IsTrue(results.Any(r => r.MemberNames.Contains(nameof(ContactFormRequest.Name))));
}
[TestMethod]
public void NameTooShort_FailsValidation()
{
// Arrange
request.Name = "J";
// Act
var validationContext = new ValidationContext(request);
var results = new List<ValidationResult>();
var isValid = Validator.TryValidateObject(request, validationContext, results, true);
// Assert
Assert.IsFalse(isValid);
}
[TestMethod]
public void InvalidEmail_FailsValidation()
{
// Arrange
request.Email = "not-an-email";
// Act
var validationContext = new ValidationContext(request);
var results = new List<ValidationResult>();
var isValid = Validator.TryValidateObject(request, validationContext, results, true);
// Assert
Assert.IsFalse(isValid);
}
[TestMethod]
public void MessageTooShort_FailsValidation()
{
// Arrange
request.Message = "short";
// Act
var validationContext = new ValidationContext(request);
var results = new List<ValidationResult>();
var isValid = Validator.TryValidateObject(request, validationContext, results, true);
// Assert
Assert.IsFalse(isValid);
}
[TestMethod]
public void MessageTooLong_FailsValidation()
{
// Arrange
request.Message = new string('a', 5001);
// Act
var validationContext = new ValidationContext(request);
var results = new List<ValidationResult>();
var isValid = Validator.TryValidateObject(request, validationContext, results, true);
// Assert
Assert.IsFalse(isValid);
}
}
Integration Testing¶
Testing Controller Endpoints¶
namespace Sky.Cms.Api.Shared.Tests.Controllers;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System.Net;
using System.Net.Http.Json;
using Sky.Cms.Api.Shared.Extensions;
using Sky.Cms.Api.Shared.Models;
[TestClass]
public class ContactApiControllerIntegrationTests
{
private TestServer testServer;
private HttpClient client;
[TestInitialize]
public void Setup()
{
var builder = WebApplication.CreateBuilder();
builder.Services.AddAntiforgery();
builder.Services.AddLogging();
builder.Services.AddControllers();
builder.Services.AddRateLimiter(options =>
{
ContactApiServiceExtensions.ConfigureContactApiRateLimiting(options);
});
builder.Configuration["ContactApi:AdminEmail"] = "[email protected]";
builder.Services.AddContactApi(builder.Configuration);
var app = builder.Build();
app.UseRouting();
app.UseRateLimiter();
app.MapControllers();
testServer = new TestServer(app);
client = testServer.CreateClient();
}
[TestMethod]
public async Task GetContactScript_ReturnsJavaScript()
{
// Act
var response = await client.GetAsync("/_api/contact/skycms-contact.js");
// Assert
Assert.AreEqual(HttpStatusCode.OK, response.StatusCode);
Assert.AreEqual("application/javascript", response.Content.Headers.ContentType?.MediaType);
}
[TestMethod]
public async Task PostSubmit_WithValidRequest_ReturnsSuccess()
{
// Arrange
var request = new ContactFormRequest
{
Name = "Integration Test",
Email = "[email protected]",
Message = "This is an integration test message"
};
// Act
var response = await client.PostAsJsonAsync("/_api/contact/submit", request);
// Assert
Assert.AreEqual(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadAsAsync<ContactFormResponse>();
Assert.IsTrue(result.Success);
}
[TestMethod]
public async Task PostSubmit_WithInvalidEmail_ReturnsBadRequest()
{
// Arrange
var request = new ContactFormRequest
{
Name = "Test",
Email = "invalid-email",
Message = "This is a test message with invalid email"
};
// Act
var response = await client.PostAsJsonAsync("/_api/contact/submit", request);
// Assert
Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode);
}
[TestCleanup]
public void Cleanup()
{
client?.Dispose();
testServer?.Dispose();
}
}
Performance Testing¶
Load Testing Example¶
namespace Sky.Cms.Api.Shared.Tests.Performance;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System.Diagnostics;
using System.Net.Http.Json;
using Sky.Cms.Api.Shared.Models;
[TestClass]
public class ContactApiPerformanceTests
{
private HttpClient client;
[TestInitialize]
public void Setup()
{
// Initialize test server
client = new HttpClient { BaseAddress = new Uri("https://localhost:5001") };
}
[TestMethod]
[Timeout(5000)] // 5 second timeout
public async Task PostSubmit_CompletesWithinAcceptableTime()
{
// Arrange
var request = new ContactFormRequest
{
Name = "Performance Test",
Email = "[email protected]",
Message = "This is a performance test message"
};
var stopwatch = Stopwatch.StartNew();
// Act
var response = await client.PostAsJsonAsync("/_api/contact/submit", request);
stopwatch.Stop();
// Assert
Assert.IsTrue(response.IsSuccessStatusCode);
Assert.IsTrue(stopwatch.ElapsedMilliseconds < 1000,
$"Request took {stopwatch.ElapsedMilliseconds}ms, expected < 1000ms");
}
}
Manual Testing¶
cURL Commands¶
curl -X GET "https://localhost:5001/_api/contact/skycms-contact.js" \
-H "Accept: application/javascript"
curl -X POST "https://localhost:5001/_api/contact/submit" \
-H "Content-Type: application/json" \
-d '{
"name": "John Doe",
"email": "[email protected]",
"message": "This is a test message that is at least 10 characters long"
}'
Postman Collection¶
Create a Postman collection with these requests:
- Get Contact Script
- Method: GET
- URL:
{{base_url}}/_api/contact/skycms-contact.js -
Headers: Accept: application/javascript
-
Submit Valid Form
- Method: POST
- URL:
{{base_url}}/_api/contact/submit -
Body (JSON):
{ "name": "Test User", "email": "[email protected]", "message": "This is a test message" } -
Submit with CAPTCHA
- Method: POST
- URL:
{{base_url}}/_api/contact/submit -
Body (JSON):
{ "name": "Test User", "email": "[email protected]", "message": "This is a test message", "captchaToken": "your-captcha-token" }
Test Coverage Goals¶
Aim for the following coverage targets:
| Component | Target Coverage |
|---|---|
| Handlers | 90%+ |
| Services | 90%+ |
| Models/DTOs | 100% (validation) |
| Controllers | 85%+ |
| Overall | 85%+ |
Continuous Integration¶
GitHub Actions Example¶
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup .NET
uses: actions/setup-dotnet@v1
with:
dotnet-version: '9.0.x'
- name: Restore dependencies
run: dotnet restore
- name: Build
run: dotnet build --no-restore
- name: Test
run: dotnet test --no-build --verbosity normal
- name: Upload coverage
uses: codecov/codecov-action@v2
with:
files: ./coverage.opencover.xml
Best Practices¶
- Isolate dependencies: Use mocks for external services
- Test behavior, not implementation: Focus on what, not how
- Use descriptive test names: Should read like documentation
- Keep tests independent: No test should depend on another
- Use Arrange-Act-Assert pattern: Clear structure for readability
- Test edge cases: Empty strings, nulls, boundary values
- Test error paths: Not just the happy path
- Use test data builders: Reduce boilerplate in test setup
- Mock external APIs: Don't call real APIs in tests
- Verify logging: Ensure important events are logged
Running Tests¶
dotnet test
dotnet test --filter "ClassName=MyTest"
dotnet test /p:CollectCoverage=true /p:CoverageFormat=opencover
dotnet test --verbosity normal
dotnet watch test