Working with JSON in C# API Tests
Alright, let's talk about the universal language of modern APIs: JSON. You now know how to send an API request and get a response back as a raw string. But what do you do with that string? It's often a blob of text that's hard to work with directly.
This is where the magic happens. We need to translate that JSON text into a structured C# object that we can easily use in our tests. We also need to do the reverse – translate our C# test data into a JSON string to send in our request bodies. These two fundamental processes are called deserialization and serialization.
Mastering this translation is non-negotiable for any serious API tester. It's the skill that turns messy data into clean, readable, and robust automated tests. Let's get started! ⚙️
What is JSON?
JSON stands for JavaScript Object Notation. It's a lightweight, text-based format for data exchange that's easy for humans to read and for machines to parse. Despite "JavaScript" being in the name, it is completely language-independent and has become the undisputed standard for web APIs.
JSON is built on two simple structures:
- Objects: Represented by curly braces
{}, objects are unordered collections of key-value pairs. Keys are strings, and values can be strings, numbers, booleans, other JSON objects, arrays, or null. - Arrays: Represented by square brackets
[], arrays are ordered lists of values. These values can be any JSON data type, including objects and other arrays.
Let's look at a comprehensive example of JSON data:
{
"id": 1,
"title": "A blog post title",
"published": true,
"author": {
"id": 123,
"name": "Testy McTester"
},
"tags": ["api", "testing", "csharp"],
"views": null
}
In this example, we see a JSON object containing various data types: strings, numbers, booleans, nested objects, arrays, and null values. This structure is typical of what you might encounter in API responses.
Modeling JSON with C# Classes
To work with JSON in a strongly-typed language like C#, we need a "shape" that our code can understand. We create C# classes that mirror the structure of the JSON data. In this context, you'll often hear two terms: POCO and DTO.
A POCO (Plain Old CLR Object) is a simple class. It doesn't have any special attributes or dependencies on external frameworks. It just contains properties to hold data or some basic logic. POCOs are perfect when the C# property names and the JSON keys match exactly.
A DTO (Data Transfer Object) is a class specifically designed to carry data between processes, like between your test client and an API. While it can be a simple POCO, it often includes attributes to control how data is serialized or deserialized, such as mapping a JSON key with a different name to a C# property.
POCO vs DTO: What's the Difference?
While the terms are sometimes used interchangeably in casual conversation, they represent different design intents. Understanding this distinction will make you a more precise developer.
| Feature | POCO (Plain Old CLR Object) | DTO (Data Transfer Object) |
|---|---|---|
| Primary Purpose | A general-purpose object representing data within your application's logic. | A specific "data contract" for transferring data across boundaries (like an API call). |
| Complexity | As simple as possible. Just properties and maybe some basic methods. | Can be simple, but is designed to be shaped for the transfer process. |
| Attributes | Generally avoids attributes related to frameworks or serialization. | Frequently uses attributes (e.g., [JsonPropertyName]) to control serialization behavior. |
| Typical Use Case | Ideal for internal app models where the JSON structure mirrors C# conventions – perfect for straightforward deserialization without extra configuration. | Tailored for communication across systems – used when API payloads require renamed, reshaped, or filtered data during serialization or deserialization. |
The bottom line: All DTOs are POCOs, but not all POCOs are DTOs. The moment you add an attribute to control how it talks to the "outside world", it's more accurate to call it a DTO.
Deserialization – JSON to C# Object
Deserialization is the process of converting a JSON string (like an API response) into a native C# object. This is what lets us write clean, readable assertions against the data we receive.
The Tools of the Trade
In the .NET world, two libraries dominate this space. We're gonna look at both, as you'll encounter each of them in your career.
- System.Text.Json: This is Microsoft's modern, high-performance, built-in library. It's the standard for new .NET projects.
- Newtonsoft.Json (Json.NET): For years, this was the de facto standard. It's a powerful and flexible third-party library that you will find in a massive number of existing enterprise projects.
Creating a C# Model
Let's start with the most common case: deserializing a JSON response where the keys match our C# properties. Here, a simple POCO is all we need.
Let's say we get a simple "todo" item from an API.
// This simple POCO has no special attributes.
// Its property names exactly match the JSON keys.
// It works with both System.Text.Json and Newtonsoft.Json.
public class Todo
{
public int UserId { get; set; }
public int Id { get; set; }
public string Title { get; set; }
public bool Completed { get; set; }
}
By using a POCO, we ensure that our class is lightweight and easy to work with, suitable for mapping JSON data directly to C# objects.
Deserializing JSON
Now, let's convert a JSON string into an instance of this Todo class using both libraries.
// Add this to your using statements: using System.Text.Json;
// The JSON string we receive from an API
string jsonString = @"{""userId"":1,""id"":1,""title"":""delectus aut autem"",""completed"":false}";
// System.Text.Json is case-sensitive by default. We can override it using a custom option.
var options = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
};
Todo myTodo = JsonSerializer.Deserialize<Todo>(jsonString, options);
Console.WriteLine($"Title: {myTodo.Title}");
Console.ReadKey();
// First, add the Nuget package: dotnet add package Newtonsoft.Json
// Then, add this to your using statements: using Newtonsoft.Json;
// The JSON string we receive from an API
string jsonString = @"{""userId"":1,""id"":1,""title"":""delectus aut autem"",""completed"":false}";
Todo myTodo = JsonConvert.DeserializeObject<Todo>(jsonString);
Console.WriteLine($"Title: {myTodo.Title}");
Console.ReadKey();
Serialization – C# Object to JSON
Serialization is the reverse process: turning a C# object into a JSON string. This is essential when you need to provide a body for a POST or PUT request to create or update data via an API.
Creating a JSON Payload
Let's create a new Todo object in C# and serialize it into a JSON string, ready to be sent as a request body.
var newTodo = new Todo
{
UserId = 10,
Title = "Learn API Testing",
Completed = false
};
// For pretty-printing, we can pass in options.
var options = new JsonSerializerOptions { WriteIndented = true };
string jsonPayload = JsonSerializer.Serialize(newTodo, options);
Console.WriteLine(jsonPayload);
Console.ReadKey();
var newTodo = new Todo
{
UserId = 10,
Title = "Learn API Testing",
Completed = false
};
// The second argument enables pretty-printing.
string jsonPayload = JsonConvert.SerializeObject(newTodo, Formatting.Indented);
Console.WriteLine(jsonPayload);
Console.ReadKey();
A Shortcut – Payloads with Anonymous Types
So far, we've been defining a formal class (like our Todo POCO) every time we want to work with a JSON structure. This is a great practice for creating reusable, strongly-typed models, especially for deserializing responses. It provides compile-time safety, IntelliSense support, and makes your code more maintainable.
But what about for a simple, one-off POST request where you only need to define the request body once? Sometimes, creating an entire new class file feels like overkill – especially in test scenarios where you're just trying to quickly send some data to an API endpoint. For these situations, C# offers a convenient feature: Anonymous Types.
An anonymous type is essentially a simple, unnamed class that you create on the fly. You define its properties and their values at the moment you instantiate it, and the C# compiler handles the rest.
Basic Anonymous Type Usage
Here's how you can create and serialize an anonymous type for a JSON payload:
// Instead of defining an entirely new class for this payload, we'll use an anonymous type
var requestPayload = new
{
Title = "My Awesome New Post",
Body = "This was created with an anonymous type!",
UserId = 15,
Tags = new[] { "tutorial", "csharp", "json" },
CreatedAt = DateTime.UtcNow,
IsPublished = true
};
// The serializer works with it perfectly!
string jsonBody = JsonSerializer.Serialize(requestPayload, new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
Console.WriteLine(jsonBody);
// Output:
// {
// "title": "My Awesome New Post",
// "body": "This was created with an anonymous type!",
// "userId": 15,
// "tags": ["tutorial", "csharp", "json"],
// "createdAt": "2025-07-01T10:30:45.123Z",
// "isPublished": true
// }
Projection Initializers: Even Shorter Syntax
If you already have variables with the exact names you want your properties to have, C# offers an even shorter syntax called a projection initializer. If you don't specify the member names in the anonymous type, the compiler gives the new members the same name as the variables being used to initialize them.
string Title = "A Title from a Variable";
int UserId = 20;
// Notice we didn't have to write "Title = Title" or "UserId = UserId".
// The compiler infers the property names from the variable names.
// We only specified a property that doesn't correspond to a declared variable ('IsComplete').
var payloadFromVariables = new { Title, UserId, IsComplete = false };
string jsonBodyFromVars = JsonSerializer.Serialize(payloadFromVariables, new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
Console.WriteLine(jsonBodyFromVars);
// Output:
// {
// "title": "A Title from a Variable",
// "userId": 20,
// "isComplete": false
// }
Nested Anonymous Types
Anonymous types can also contain other anonymous types, making them useful for more complex JSON structures:
var complexPayload = new
{
User = new
{
Id = 123,
Name = "John Doe",
Email = "[email protected]"
},
Post = new
{
Title = "Understanding Anonymous Types",
Content = "This is a comprehensive guide...",
Metadata = new
{
WordCount = 1250,
ReadingTime = "5 minutes",
Tags = new[] { "csharp", "programming", "tutorial" }
}
},
Action = "create_post",
Timestamp = DateTimeOffset.UtcNow
};
Anonymous Types vs Traditional Classes
| Aspect | Anonymous Types | Traditional Classes |
|---|---|---|
| Definition Time | Defined inline at instantiation | Defined separately, typically in own file |
| Reusability | Single use only | Reusable across entire application |
| Method Parameters | Cannot be used as parameter types | Can be used as parameter/return types |
| IntelliSense | Available within scope | Available everywhere class is accessible |
| Documentation | No inline XML documentation support | Full XML documentation support |
| Extensibility | Cannot inherit or implement interfaces | Full inheritance and interface support |
When to Use Anonymous Types (and When Not To)
Anonymous types are a fantastic tool, but they have clear limitations. Knowing when to use them is a mark of a good test automation engineer.
✅ Perfect for: Simple, one-off JSON request bodies that are defined and used entirely within a single test method or API endpoint. They keep your code concise and reduce ceremony.
✅ Great for: Quick prototyping, temporary data structures, and scenarios where you need to group related values together briefly.
✅ Ideal for: LINQ projections where you're transforming data into a temporary structure for immediate use.
❌ Avoid for: Data structures that you need to reuse across multiple methods, classes, or projects. You cannot use an anonymous type as a method parameter or return type.
❌ Don't use for: Complex business objects that need validation, custom methods, or represent core domain concepts. For any reusable data structure, always define a proper DTO class.
❌ Skip for: API response models where you need strong typing for reliable deserialization and future maintenance.
Advanced JSON Handling with DTOs
What happens when things aren't so simple? If the JSON property names don't match our C# conventions, or if we're dealing with a list of items, we need more control. This is where DTOs with attributes become essential.
Mapping JSON Properties
Let's say an API gives us user_id and first_name (snake_case). We can map these to our C# Id and FirstName properties (PascalCase) using attributes, turning our class into a DTO.
Here's how you tell the serializer how to map a JSON key to specific C# properties using DTOs.
using System.Text.Json.Serialization;
public class UserDto
{
[JsonPropertyName("user_id")]
public int Id { get; set; }
[JsonPropertyName("first_name")]
public string FirstName { get; set; }
}
using Newtonsoft.Json;
public class UserDto
{
[JsonProperty("user_id")]
public int Id { get; set; }
[JsonProperty("first_name")]
public string FirstName { get; set; }
}
Handling Collections (JSON Arrays)
When an API returns a JSON array, you simply deserialize it into a List<T> of your C# POCO or DTO. The serializer handles the one-to-many mapping automatically. You can then use LINQ to query and assert on the results.
string jsonArrayString = @"[
{ ""id"": 1, ""title"": ""Task 1"", ""completed"": true },
{ ""id"": 2, ""title"": ""Task 2"", ""completed"": false }
]";
// The deserialization call is the same, just with List<Todo>
var options = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
};
List<Todo> todos = JsonSerializer.Deserialize<List<Todo>>(jsonArrayString, options);
// Now we can assert on the collection
Assert.That(todos.Count, Is.EqualTo(2));
Assert.That(todos.Any(todo => todo.Title == "Task 2" && !todo.Completed));
string jsonArrayString = @"[
{ ""id"": 1, ""title"": ""Task 1"", ""completed"": true },
{ ""id"": 2, ""title"": ""Task 2"", ""completed"": false }
]";
// The deserialization call is the same, just with List<Todo>
List<Todo> todos = JsonConvert.DeserializeObject<List<Todo>>(jsonArrayString);
// Now we can assert on the collection
Assert.That(todos.Count, Is.EqualTo(2));
Assert.That(todos.Any(todo => todo.Title == "Task 2" && !todo.Completed));
Key Takeaways
- POCOs are simple C# classes used for direct 1:1 mapping of JSON data, while DTOs use attributes to control the serialization process for more complex scenarios.
- Deserialization turns JSON text into C# objects, making data easy to test. Serialization turns C# objects into JSON text for request bodies.
- For new projects, use the built-in
System.Text.Json. For legacy projects, you'll frequently see and useNewtonsoft.Json. - Anonymous types are perfect for quick, throwaway data structures when you need to shape a payload inline – without cluttering your codebase with new classes.
- Use attributes like
[JsonPropertyName](for System.Text.Json) or[JsonProperty](for Newtonsoft.Json) in a DTO to handle mismatches between JSON keys and C# property names. - For robust tests, always assert on the properties of a deserialized object, not on the raw JSON string.
Deepen Your JSON Knowledge
- Microsoft Docs: JSON serialization and deserialization in .NET The official and comprehensive guide to using the modern System.Text.Json library.
- Microsoft Docs: Migrate from Newtonsoft.Json to System.Text.Json An essential guide for understanding the differences and converting existing projects.
- Microsoft Docs: Anonymous types The official reference on the Anonymous Types.
- JSON.NET: Samples Over 100 code samples covering Json.NET's most commonly used functionality.