Writing Your First API Test with NUnit
This is the moment we've been building towards! You've learned about HTTP, explored REST principles, and mastered handling JSON. Now it's time to put all those pieces together and build your first real, automated API test. 🚀
We'll use the NUnit framework to structure our tests, orchestrate our HttpClient calls, and make powerful assertions to verify that the API is behaving exactly as we expect. This is where your journey as a true API test automation engineer begins.
By the end of this lesson, you'll have written two complete, meaningful API tests and gained the core pattern you'll use for all future API test automation.
The Anatomy of an API Test
Every good automated test follows a clear, predictable pattern. The most common and effective one is the Arrange-Act-Assert (AAA) pattern. For API testing, it breaks down like this:
- Arrange: Get everything ready for your test. This means creating an
HttpClientinstance, defining the URL for the API endpoint you're targeting, and having your C# model class ready for deserialization. - Act: Perform the main action. In our case, this is making the actual API call (e.g.,
await client.GetAsync(...)) and then processing the result, which usually involves deserializing the JSON response body into your C# object. - Assert: Verify the outcome. This is the most critical part! For a successful API call, this is a two-part check: first, you validate the HTTP response status code (e.g., 200 OK), and second, you validate the data within the deserialized object.
We'll use the free and public JSONPlaceholder API for our tests, which is a fantastic sandbox for practicing.
Structuring the Test Class
When building API tests with NUnit and HttpClient, it's important to balance test isolation with resource efficiency. Good structure helps you avoid subtle bugs, maintain a clean environment, and maximize the performance of your test suite.
Key Principles
- Test Isolation: Each test should be independent – no test should rely on the outcome or state of another.
- Resource Management: Network resources (like sockets) are expensive. Creating a new
HttpClientfor every test can cause "socket exhaustion" and slow down your test runs.
Recommended Test Class Structure
Here's a recommended way to set up your test class for API testing:
using NUnit.Framework;
using System.Net.Http;
using System.Threading.Tasks;
[TestFixture]
public class PostApiTests
{
private static HttpClient _client;
[OneTimeSetUp]
public void GlobalSetup()
{
// Runs once before all tests: create a single, reusable HttpClient instance
_client = new HttpClient();
}
[OneTimeTearDown]
public void GlobalTearDown()
{
// Runs once after all tests: dispose of HttpClient to free resources
_client.Dispose();
}
[Test]
public async Task YourFirstTestGoesHere()
{
// Arrange: prepare request data
// Act: make an API call using the shared _client instance
// Assert: check the response
}
}
Notice the async Task on the test method. Because HttpClient's methods are asynchronous (they don't block your code while waiting for the network), our test methods must be marked as async Task. NUnit is smart enough to handle this and will wait for your API call to complete before finishing the test.
Implementing the "Happy Path" Test
A "happy path" test is the foundation of any API test suite. It verifies that the API works correctly under normal, expected conditions – when valid data is supplied, no errors occur, and the system behaves as designed. For our example, we'll test the GET /posts/1 endpoint, which should retrieve the post with ID = 1.
First, we need a C# class to represent the post data, which we'll deserialize the JSON into.
using System.Text.Json.Serialization;
// A simple DTO to model the Post resource
public class Post
{
[JsonPropertyName("userId")]
public int UserId { get; set; }
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("title")]
public string Title { get; set; }
[JsonPropertyName("body")]
public string Body { get; set; }
}
Now, let's write our complete test by filling in the Arrange, Act, and Assert sections.
using NUnit.Framework;
using System.Net;
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;
[TestFixture]
public class PostApiTests
{
private static HttpClient _client;
[OneTimeSetUp]
public void GlobalSetup()
{
_client = new HttpClient();
// Set a base address if you're always calling the same domain
_client.BaseAddress = new Uri("https://jsonplaceholder.typicode.com/");
}
[OneTimeTearDown]
public void GlobalTearDown()
{
_client.Dispose();
}
[Test]
public async Task GetPost_WhenPostExists_ShouldReturnOkAndCorrectData()
{
// Arrange
var expectedPostId = 1;
var requestUri = $"posts/{expectedPostId}";
// Act
HttpResponseMessage response = await _client.GetAsync(requestUri);
// Assert - Part 1: Check the HTTP Response
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), "Expected a 200 OK response.");
// ASSERT – Part 2: Validate headers (optional, but often useful)
Assert.That(response.Content.Headers.ContentType.MediaType, Is.EqualTo("application/json"), "Expected content type application/json.");
// ASSERT – Part 3: Deserialize and validate response body
string responseBody = await response.Content.ReadAsStringAsync();
Post post = JsonSerializer.Deserialize<Post>(responseBody);
// (Optional) Output the post for debug visibility in test results
TestContext.WriteLine($"Retrieved Post: Id={post?.Id}, UserId={post?.UserId}, Title={post?.Title}");
Assert.That(post, Is.Not.Null, "Post object should not be null.");
Assert.That(post.Id, Is.EqualTo(expectedPostId), "Returned post ID does not match.");
Assert.That(post.UserId, Is.GreaterThan(0), "User ID should be a positive integer.");
Assert.That(post.Title, Is.Not.Empty, "Post title should not be empty.");
Assert.That(post.Body, Is.Not.Empty, "Post body should not be empty.");
}
}
By following the Arrange-Act-Assert pattern in our "happy path" test, we establish a clear and maintainable structure for our tests. Here are some key takeaways and additional considerations:
- Reusability: By managing the
HttpClientinstance efficiently (using a static field and reusing it across tests), we adhere to best practices and prevent resource exhaustion. - Comprehensive Assertions: It's crucial to validate both the HTTP response status and the entirety of the response, including the body and headers. This ensures that the request is successful, the returned data is accurate and complete, and the headers contain vital information such as content type, caching policies, and authentication tokens.
- Descriptive Assertion Messages: Including clear and descriptive messages in assertions enhances the debugging process. When a test fails, a well-crafted message can quickly identify the discrepancy between expected and actual outcomes, thereby saving time and reducing frustration during troubleshooting.
- Maintainability: Structuring tests with clear separation of setup, action, and assertion phases makes it easier to maintain and update tests as your API evolves.
- Traceability: Utilizing logging within test contexts can be invaluable for diagnosing issues. Logging responses, request details, and intermediate states can provide a wealth of information when tests fail unexpectedly.
- Robustness: Additional assertions and checks can be added to cover edge cases and ensure data integrity, making your tests more robust and reliable.
By following these practices, you'll be able to create effective and efficient tests that validate the functionality of your API under typical conditions. Incorporating detailed assertion messages, verifying headers, and employing test context logging will further enhance the quality and maintainability of your test suite.
Don't Forget the "Sad Path"
Great API testing isn't just about confirming that your system works when everything goes right – it's equally about ensuring your system fails safely, clearly, and predictably. These are your "sad path" or negative tests. They prove that your API gracefully handles invalid input, missing data, or forbidden actions, and communicates problems in a controlled, secure way.
Let's add a second test to check that requesting a non-existent post returns a 404 Not Found status code.
[Test]
public async Task GetPost_WhenPostDoesNotExist_ShouldReturnNotFound()
{
// Arrange
var nonExistentPostId = 999999;
var requestUri = $"posts/{nonExistentPostId}";
// Act
HttpResponseMessage response = await _client.GetAsync(requestUri);
// Assert
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NotFound));
}
Notice that in this negative test, we don't try to deserialize the response body. That's intentional! A 404 response may have no body, or it might contain HTML or plain text instead of the JSON we expect for successful responses. Trying to deserialize it could throw an exception and obscure the real result of the test. The goal of this test is to verify the 404 Not Found status, and we've done that successfully.
Other Common "Sad Path" Scenarios
A thorough API test suite should cover a variety of negative and edge cases, for example:
- Missing or invalid input (
400 Bad Request). - Unauthorized access (
401 Unauthorized). - Forbidden action (
403 Forbidden). - Unsupported HTTP methods (
405 Method Not Allowed). - Conflict/Error states (
409 Conflict). - Server errors (
500 Internal Server Error). - Request timeouts and network errors.
Think Beyond the Happy Path
For each endpoint, always ask yourself: "What could go wrong?" Then write a test to verify your API handles that situation properly. By anticipating failure cases, you're not just writing tests – you're designing resilient systems. This mindset helps you:
- Catch issues before users do.
- Improve API documentation by clarifying edge cases.
- Foster confidence in production stability.
By implementing comprehensive negative testing alongside your happy path tests, you create a robust test suite that validates both success and failure scenarios.
Key Takeaways
- API tests in NUnit must be marked as
async Taskto correctly work with asynchronous methods fromHttpClient. - The Arrange-Act-Assert pattern provides a clean and readable structure for your tests.
- For a successful response ("happy path"), you must assert on both the HTTP status code and the deserialized response body.
- For an expected failure ("sad path"), your primary assertion is on the HTTP status code (e.g.,
NotFound,BadRequest). - A thorough API test suite should cover both positive and negative test cases.