Tutorials / Build a REST API with C# and .NET · Lesson 23 of 27
On this page
Update and Delete: PUT and DELETE
The API can read books (reading books) and add them (creating a book). Two jobs are left to complete the set the bookshop needs: correct a book that is already there, and remove one.
Our goal: PUT /books/{id} replaces a book, DELETE /books/{id} removes one, and both answer 204 No Content, or 404 when the book does not exist. We write the service methods and the endpoints, test them, and try them by hand.
Here is the route:
- Meet PUT, which replaces a whole book, and see why PATCH is left out.
- Add
UpdateAsyncandDeleteAsyncto the service, and to the fake. - Add the two endpoints, with an answer that has no body.
- Run them, including the cases that go wrong.
- Test the service and the controller, and meet a test trap about what EF Core remembers.
- Check by hand in the test page.
This lesson leaves out partial updates, protection against two people editing at once, the clean fix for the shortcut of reading and creating books (a later lesson), and the list of books in the stock report (a later lesson).
Where we are
This is the solution from the end of the creating lesson. We touch these files:
BookShop/
BookShop.slnx
Directory.Build.props
Directory.Packages.props
src/
BookShop.Api/
Program.cs
GlobalExceptionHandler.cs
Controllers/
BooksController.cs <- changes (two new endpoints)
BookShop.Data/
Book.cs
BookShopDbContext.cs
IBookService.cs <- changes (two new methods)
BookService.cs <- changes (the two methods)
Migrations/
tests/
BookShop.Data.Tests/
BookServiceTests.cs <- changes (five new tests)
BookValidationTests.cs
BookShop.Api.Tests/
FakeBookService.cs <- changes (the two methods)
FakeLogger.cs
BooksControllerTests.cs <- changes (five new tests)
GlobalExceptionHandlerTests.cs
Run every command from the solution folder, BookShop/. No project is added, so there is no solution map.
PUT replaces the whole book
HTTP and REST concepts gave PUT its meaning: replace the thing at this address. A client that wants to change the price of Clean Code does not send "change the price". It sends the whole book as it should now be, to the address of that book, /books/2, and the API swaps the old one for the new one.
That is simple to understand and simple to build. It also means the client must send every field, even those it did not change.
And PATCH?
The second row of the picture is PATCH: send only the fields that change, for example { "price": 29.90 }. It saves the client some typing, and it is real and widely used. We do not build it, for one reason that beginners meet quickly. The server must tell "this field was not sent, so leave it" from "this field was sent as empty, so clear it". A plain Book cannot say it: a Price of 0 could mean either. Doing it right needs a way to describe the patch itself, and there are standard formats for that. It is a good exercise for later. This course uses PUT for every change.
The service methods
The shape is the same as in the books service and the creating lesson: the work in the service, the HTTP decisions in the controller. Both new methods need a question answered first: does this book exist? The service answers with a plain bool. Open src/BookShop.Data/IBookService.cs. The whole file is:
namespace BookShop.Data;
public interface IBookService
{
Task<List<Book>> GetAllAsync();
Task<Book?> GetByIdAsync(int id);
Task<Book> CreateAsync(Book book);
Task<bool> UpdateAsync(int id, Book book); // new
Task<bool> DeleteAsync(int id); // new
}
true means "found it, and did the work". false means "there is no such book". As before, the service says what happened, and the controller decides what that means in HTTP. Note that UpdateAsync takes the id and the book. The address says which book, the body says what it should become.
Open src/BookShop.Data/BookService.cs. The whole file is:
using Microsoft.EntityFrameworkCore;
namespace BookShop.Data;
public class BookService : IBookService
{
private readonly BookShopDbContext _context;
public BookService(BookShopDbContext context)
{
_context = context;
}
public async Task<List<Book>> GetAllAsync()
{
return await _context.Books.OrderBy(b => b.Title).ToListAsync();
}
public async Task<Book?> GetByIdAsync(int id)
{
return await _context.Books.FindAsync(id);
}
public async Task<Book> CreateAsync(Book book)
{
_context.Books.Add(book);
await _context.SaveChangesAsync();
return book;
}
public async Task<bool> UpdateAsync(int id, Book book) // new
{ // new
var existing = await _context.Books.FindAsync(id); // new
if (existing == null) // new
{ // new
return false; // new
} // new
// new
existing.Title = book.Title; // new
existing.Author = book.Author; // new
existing.Price = book.Price; // new
existing.Stock = book.Stock; // new
existing.Category = book.Category; // new
await _context.SaveChangesAsync(); // new
return true; // new
} // new
// new
public async Task<bool> DeleteAsync(int id) // new
{ // new
var existing = await _context.Books.FindAsync(id); // new
if (existing == null) // new
{ // new
return false; // new
} // new
// new
_context.Books.Remove(existing); // new
await _context.SaveChangesAsync(); // new
return true; // new
} // new
}
Read UpdateAsync in order:
FindAsync(id)loads the book that is in the table now. If there is none, we stop and sayfalse.- The five lines that copy write the new values onto the book that EF Core loaded. EF Core watches that object. It notices which properties changed, and it will write them.
SaveChangesAsync()sends theUPDATE. (When the loaded book is changed, the same call that saved a new book now saves the change.)Idis not copied. The address chose the book, and the body cannot move it to another row. If the body carries anid, it is ignored.
DeleteAsync has the same first half. Then Remove(existing) marks the row for deletion, and SaveChangesAsync() sends the DELETE.
Why copy field by field? EF Core has a shorter way, _context.Update(book). We copy by hand because the code then states the rule it implements: the address picks the book, the body picks the new values, and Id is never touched. One honest warning: if a column is added to Book next month, this method must be extended, or the new column is quietly never updated.
💡 What if two people edit the same book at once? The later
PUTsimply replaces the earlier one, and nobody is told. This is called a lost update. The usual cure is a version number on the row, and we leave it out of this course.
The fake must keep up, or the build fails again with CS0535, as it did when creating a book. Open tests/BookShop.Api.Tests/FakeBookService.cs. The whole file is:
using BookShop.Data;
namespace BookShop.Api.Tests;
public class FakeBookService : IBookService
{
public List<Book> Books { get; } = new List<Book>();
public Task<List<Book>> GetAllAsync()
{
return Task.FromResult(Books);
}
public Task<Book?> GetByIdAsync(int id)
{
var book = Books.FirstOrDefault(b => b.Id == id);
return Task.FromResult(book);
}
public Task<Book> CreateAsync(Book book)
{
book.Id = Books.Count + 1;
Books.Add(book);
return Task.FromResult(book);
}
public Task<bool> UpdateAsync(int id, Book book) // new
{ // new
var index = Books.FindIndex(b => b.Id == id); // new
if (index < 0) // new
{ // new
return Task.FromResult(false); // new
} // new
// new
book.Id = id; // new
Books[index] = book; // new
return Task.FromResult(true); // new
} // new
// new
public Task<bool> DeleteAsync(int id) // new
{ // new
var existing = Books.FirstOrDefault(b => b.Id == id); // new
if (existing == null) // new
{ // new
return Task.FromResult(false); // new
} // new
// new
Books.Remove(existing); // new
return Task.FromResult(true); // new
} // new
}
The fake does the same job in a smaller way: it swaps the book in its list, or removes it, and answers true or false.
The endpoints
Open src/BookShop.Api/Controllers/BooksController.cs. The whole file is:
using BookShop.Data;
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("books")]
public class BooksController : ControllerBase
{
private readonly IBookService _bookService;
public BooksController(IBookService bookService)
{
_bookService = bookService;
}
[HttpGet]
public async Task<ActionResult<List<Book>>> GetAll()
{
var books = await _bookService.GetAllAsync();
return books;
}
[HttpGet("{id}")]
[ProducesResponseType<Book>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<Book>> GetById(int id)
{
var book = await _bookService.GetByIdAsync(id);
if (book == null)
{
return NotFound();
}
return book;
}
[HttpPost]
[ProducesResponseType<Book>(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<Book>> Create(Book book)
{
var created = await _bookService.CreateAsync(book);
return CreatedAtAction(nameof(GetById), new { id = created.Id }, created);
}
[HttpPut("{id}")] // new
[ProducesResponseType(StatusCodes.Status204NoContent)] // new
[ProducesResponseType(StatusCodes.Status400BadRequest)] // new
[ProducesResponseType(StatusCodes.Status404NotFound)] // new
public async Task<IActionResult> Update(int id, Book book) // new
{ // new
var found = await _bookService.UpdateAsync(id, book); // new
if (!found) // new
{ // new
return NotFound(); // new
} // new
return NoContent(); // new
} // new
// new
[HttpDelete("{id}")] // new
[ProducesResponseType(StatusCodes.Status204NoContent)] // new
[ProducesResponseType(StatusCodes.Status404NotFound)] // new
public async Task<IActionResult> Delete(int id) // new
{ // new
var found = await _bookService.DeleteAsync(id); // new
if (!found) // new
{ // new
return NotFound(); // new
} // new
return NoContent(); // new
} // new
}
[HttpPut("{id}")]and[HttpDelete("{id}")]are the verbs from the start of the course, with the id in the address as inGetById.Update(int id, Book book)has both kinds of input:idcomes from the address,bookcomes from the body.IActionResultis new.ActionResult<Book>means "a book, or another answer". These two methods never send a book back, so there is nothing to put in the angle brackets.IActionResultsimply means "some answer".NoContent()builds a 204 No Content: it worked, and there is nothing to send. Why not200with the updated book? Some APIs do. We chose204because the client just sent the book and does not need it back, and aDELETEhas no book left to send. A client that wants to see the result canGETit.- The validation rules apply to
PUTtoo, because the body is the sameBook. APUTwith an empty title never reachesUpdate. - The three
[ProducesResponseType]lines tell the description about the answers, as before: all of them, or the page shows only what it can guess.
The order matters. The check by [ApiController] comes first, so a PUT to an unknown id with a bad body is answered 400, not 404: the body is turned away before the service is asked anything.
Run it
Start the app (dotnet run --project src/BookShop.Api, or the green button in Visual Studio). This is the table from reading books, with its three books, and the examples below change them. First, PUT a new version of book 2: a lower price and more copies.
curl -i -X PUT http://localhost:5101/books/2 -H "Content-Type: application/json" -d '{"title":"Clean Code","author":"Robert C. Martin","price":29.90,"stock":20,"category":"Programming"}'
HTTP/1.1 204 No Content
(The Date and Server header lines are left out.) There is no body, as promised. Did it work?
curl http://localhost:5101/books/2
{"id":2,"title":"Clean Code","author":"Robert C. Martin","price":29.9,"stock":20,"category":"Programming"}
Yes. Now the cases that go wrong. A book that does not exist:
curl -i -X PUT http://localhost:5101/books/99 -H "Content-Type: application/json" -d '{"title":"X","author":"Y","price":5,"stock":1,"category":"Z"}'
HTTP/1.1 404 Not Found
Content-Type: application/problem+json; charset=utf-8
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.5",
"title": "Not Found",
"status": 404,
"traceId": "00-4981469504874f11e35a95cb17f75f65-db944b25ebd99727-00"
}
(The real body is one long line. It is shown here with line breaks, so it is easier to read.)
A body that breaks the rules of the creating lesson:
curl -i -X PUT http://localhost:5101/books/2 -H "Content-Type: application/json" -d '{"title":"","author":"Y","price":-1,"stock":1,"category":"Z"}'
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json; charset=utf-8
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Price": [
"The field Price must be between 0.01 and 10000."
],
"Title": [
"The Title field is required."
]
},
"traceId": "00-c2d96708779e506f8670c9882b162d18-11787a8aa1839a48-00"
}
And the point of "replace the whole book": a client that sends only the price is refused, because a replacement without a title is not a book.
curl -i -X PUT http://localhost:5101/books/2 -H "Content-Type: application/json" -d '{"price":10}'
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json; charset=utf-8
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Title": [
"The Title field is required."
],
"Author": [
"The Author field is required."
],
"Category": [
"The Category field is required."
]
},
"traceId": "00-3e9aa3959032c2fe8cdfff4c87dee094-e98c9784479a642a-00"
}
What about an id in the body that disagrees with the address? We said it is ignored. Check it, by sending "id":99 to book 2:
curl -i -X PUT http://localhost:5101/books/2 -H "Content-Type: application/json" -d '{"id":99,"title":"Clean Code","author":"Robert C. Martin","price":29.90,"stock":20,"category":"Programming"}'
HTTP/1.1 204 No Content
Is book 2 still book 2?
curl http://localhost:5101/books/2
{"id":2,"title":"Clean Code","author":"Robert C. Martin","price":29.9,"stock":20,"category":"Programming"}
And did a book 99 appear?
curl -i http://localhost:5101/books/99
HTTP/1.1 404 Not Found
Book 2 is still book 2, and there is no book 99. The address won.
⚠️ The shortcut from reading and creating books is here again.
PUTtakes theBookentity, so a client can also setStockto any number it likes, as we just did when we changed 7 to 20. Whether the shop's stock should be changeable like this is a business question. The fix is the same one, in a later lesson: a request class that holds only the fields a client may change.
Delete
curl -i -X DELETE http://localhost:5101/books/3
HTTP/1.1 204 No Content
The book is gone. Delete it a second time:
curl -i -X DELETE http://localhost:5101/books/3
HTTP/1.1 404 Not Found
Content-Type: application/problem+json; charset=utf-8
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.5",
"title": "Not Found",
"status": 404,
"traceId": "00-6dbbfaf0b4dd31b5cbd3ad21695773c5-962e1795dfbee514-00"
}
Is the second answer wrong? Many APIs would say 204 again, "it is gone, which is what you wanted". We say 404, because it is the plain reading of the status: the address names no book. Either choice is honest. Note what both have in common: after one delete or after ten, the state of the table is the same, a table without book 3. A request with that property is called idempotent. GET, PUT and DELETE are idempotent, and POST is not: ten POSTs make ten books. That is why a client that loses the answer may safely retry a PUT, and must be careful with a POST.
An id that is not a number gets the same 400 as when reading books:
curl -i -X DELETE http://localhost:5101/books/abc
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json; charset=utf-8
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"id": [
"The value 'abc' is not valid."
]
},
"traceId": "00-2dac45ba540f69058cbf90a74fac3527-8b27ae0840d62664-00"
}
Put the books back
Two of the three books were changed or deleted. Later lessons want a few books, so restore them. In DB Browser, on the Execute SQL tab, run this and press Write Changes:
INSERT INTO Books (Title, Author, Price, Stock, Category) VALUES
('Refactoring', 'Martin Fowler', 44.00, 3, 'Programming');
UPDATE Books SET Price = 34.50, Stock = 7 WHERE Title = 'Clean Code';
(The new book may get an id higher than 3. The table's counter never reuses a number.)
Test it
The service
Add five tests to tests/BookShop.Data.Tests/BookServiceTests.cs. The whole file is below. A // new line above a method marks the whole method:
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace BookShop.Data.Tests;
[TestClass]
public class BookServiceTests
{
private SqliteConnection _connection = null!;
private BookShopDbContext _context = null!;
private BookService _service = null!;
[TestInitialize]
public void Setup()
{
_connection = new SqliteConnection("Data Source=:memory:");
_connection.Open();
var options = new DbContextOptionsBuilder<BookShopDbContext>()
.UseSqlite(_connection)
.Options;
_context = new BookShopDbContext(options);
_context.Database.EnsureCreated();
_service = new BookService(_context);
}
[TestCleanup]
public void Cleanup()
{
_context.Dispose();
_connection.Dispose();
}
[TestMethod]
public async Task GetAllAsync_NoBooks_ReturnsEmptyList()
{
var books = await _service.GetAllAsync();
Assert.IsEmpty(books);
}
[TestMethod]
public async Task GetAllAsync_SomeBooks_ReturnsThemOrderedByTitle()
{
_context.Books.Add(new Book { Title = "Refactoring", Author = "Martin Fowler", Price = 44.00m });
_context.Books.Add(new Book { Title = "Clean Code", Author = "Robert C. Martin", Price = 34.50m });
await _context.SaveChangesAsync();
var books = await _service.GetAllAsync();
Assert.HasCount(2, books);
Assert.AreEqual("Clean Code", books[0].Title);
Assert.AreEqual("Refactoring", books[1].Title);
}
[TestMethod]
public async Task GetByIdAsync_ExistingBook_ReturnsIt()
{
var saved = new Book { Title = "Clean Code", Author = "Robert C. Martin", Price = 34.50m };
_context.Books.Add(saved);
await _context.SaveChangesAsync();
var book = await _service.GetByIdAsync(saved.Id);
Assert.IsNotNull(book);
Assert.AreEqual("Clean Code", book.Title);
}
[TestMethod]
public async Task GetByIdAsync_UnknownId_ReturnsNull()
{
var book = await _service.GetByIdAsync(999);
Assert.IsNull(book);
}
[TestMethod]
public async Task CreateAsync_ValidBook_SavesItAndGivesItAnId()
{
var book = new Book { Title = "Clean Code", Author = "Robert C. Martin", Price = 34.50m };
var created = await _service.CreateAsync(book);
Assert.IsGreaterThan(0, created.Id);
var found = await _service.GetByIdAsync(created.Id);
Assert.IsNotNull(found);
Assert.AreEqual("Clean Code", found.Title);
}
[TestMethod]
public async Task CreateAsync_TwoBooks_GetDifferentIds()
{
var first = await _service.CreateAsync(new Book { Title = "Clean Code" });
var second = await _service.CreateAsync(new Book { Title = "Refactoring" });
Assert.AreNotEqual(first.Id, second.Id);
}
// new
[TestMethod]
public async Task UpdateAsync_ExistingBook_ReplacesItsValues()
{
var saved = new Book { Title = "Clean Code", Author = "Robert C. Martin", Price = 34.50m, Stock = 7, Category = "Programming" };
_context.Books.Add(saved);
await _context.SaveChangesAsync();
var replacement = new Book { Title = "Clean Code, 2nd edition", Author = "Robert C. Martin", Price = 29.90m, Stock = 20, Category = "Software" };
var found = await _service.UpdateAsync(saved.Id, replacement);
Assert.IsTrue(found);
_context.ChangeTracker.Clear();
var book = await _service.GetByIdAsync(saved.Id);
Assert.IsNotNull(book);
Assert.AreEqual("Clean Code, 2nd edition", book.Title);
Assert.AreEqual(29.90m, book.Price);
Assert.AreEqual(20, book.Stock);
Assert.AreEqual("Software", book.Category);
}
// new
[TestMethod]
public async Task UpdateAsync_BodyHasAnotherId_KeepsTheIdOfTheAddress()
{
var saved = new Book { Title = "Clean Code" };
_context.Books.Add(saved);
await _context.SaveChangesAsync();
await _service.UpdateAsync(saved.Id, new Book { Id = 99, Title = "Clean Code, 2nd edition" });
_context.ChangeTracker.Clear();
Assert.IsNull(await _service.GetByIdAsync(99));
var book = await _service.GetByIdAsync(saved.Id);
Assert.IsNotNull(book);
Assert.AreEqual("Clean Code, 2nd edition", book.Title);
}
// new
[TestMethod]
public async Task UpdateAsync_UnknownId_ReturnsFalse()
{
var found = await _service.UpdateAsync(999, new Book { Title = "Clean Code" });
Assert.IsFalse(found);
}
// new
[TestMethod]
public async Task DeleteAsync_ExistingBook_RemovesIt()
{
var saved = new Book { Title = "Clean Code" };
_context.Books.Add(saved);
await _context.SaveChangesAsync();
var found = await _service.DeleteAsync(saved.Id);
Assert.IsTrue(found);
_context.ChangeTracker.Clear();
Assert.IsNull(await _service.GetByIdAsync(saved.Id));
}
// new
[TestMethod]
public async Task DeleteAsync_UnknownId_ReturnsFalse()
{
var found = await _service.DeleteAsync(999);
Assert.IsFalse(found);
}
}
Most of it reads like the earlier tests. One line is new and matters: _context.ChangeTracker.Clear().
The trap: what the context remembers
EF Core's context keeps a note of every object it has loaded or saved, in a part called the change tracker. It does that so it can later tell what changed. The test uses one context for everything, so after the book is saved, the context remembers it. When the service calls FindAsync, EF Core does not go to the database. It hands back the object it already remembers, and the service changes that object in memory.
Now picture the test reading the book back with GetByIdAsync. It gets the very same object, with the new title, whether or not the change ever reached the database. A test that only reads it back this way proves nothing about the save.
ChangeTracker.Clear() makes the context forget everything it remembers. The next read has to go to the database, and shows what was really saved.
We tried it. In UpdateAsync we turned the SaveChangesAsync() line into a comment, so that nothing is saved. With Clear() in the test, the test failed, as it should:
Assert.AreEqual failed. Expected string length 23 but was 10. 'expected' expression: '"Clean Code, 2nd edition"', 'actual' expression: 'book.Title'.
Expected: "Clean Code, 2nd edition"
But was: "Clean Code"
(Only the first failure is shown. The second update test failed too.) We then deleted the Clear() line from the tests and ran them again with the save still missing: all of them passed. A test that cannot fail is worse than no test, because it gives false comfort. Remember Clear() whenever a test must prove that something reached the database. (Put the save line back before you go on.)
The controller
Add five tests to tests/BookShop.Api.Tests/BooksControllerTests.cs. The whole file is:
using BookShop.Data;
using Microsoft.AspNetCore.Mvc;
namespace BookShop.Api.Tests;
[TestClass]
public class BooksControllerTests
{
private FakeBookService _fakeService = null!;
private BooksController _controller = null!;
[TestInitialize]
public void Setup()
{
_fakeService = new FakeBookService();
_controller = new BooksController(_fakeService);
}
[TestMethod]
public async Task GetAll_TwoBooks_ReturnsBoth()
{
_fakeService.Books.Add(new Book { Id = 1, Title = "Clean Code" });
_fakeService.Books.Add(new Book { Id = 2, Title = "Refactoring" });
var result = await _controller.GetAll();
Assert.IsNotNull(result.Value);
Assert.HasCount(2, result.Value);
}
[TestMethod]
public async Task GetById_ExistingBook_ReturnsIt()
{
_fakeService.Books.Add(new Book { Id = 7, Title = "Clean Code" });
var result = await _controller.GetById(7);
Assert.IsNotNull(result.Value);
Assert.AreEqual("Clean Code", result.Value.Title);
}
[TestMethod]
public async Task GetById_UnknownId_ReturnsNotFound()
{
var result = await _controller.GetById(999);
Assert.IsInstanceOfType<NotFoundResult>(result.Result);
}
[TestMethod]
public async Task Create_ValidBook_ReturnsCreatedPointingAtTheNewBook()
{
var result = await _controller.Create(new Book { Title = "Clean Code" });
var created = result.Result as CreatedAtActionResult;
Assert.IsNotNull(created);
Assert.AreEqual("GetById", created.ActionName);
Assert.AreEqual(1, created.RouteValues!["id"]);
var book = created.Value as Book;
Assert.IsNotNull(book);
Assert.AreEqual("Clean Code", book.Title);
}
[TestMethod]
public async Task Create_ValidBook_PassesItToTheService()
{
await _controller.Create(new Book { Title = "Clean Code" });
Assert.HasCount(1, _fakeService.Books);
Assert.AreEqual("Clean Code", _fakeService.Books[0].Title);
}
// new
[TestMethod]
public async Task Update_ExistingBook_ReturnsNoContent()
{
_fakeService.Books.Add(new Book { Id = 3, Title = "Clean Code" });
var result = await _controller.Update(3, new Book { Title = "Clean Code, 2nd edition" });
Assert.IsInstanceOfType<NoContentResult>(result);
}
// new
[TestMethod]
public async Task Update_ExistingBook_PassesTheNewValuesToTheService()
{
_fakeService.Books.Add(new Book { Id = 3, Title = "Clean Code" });
await _controller.Update(3, new Book { Title = "Clean Code, 2nd edition" });
Assert.AreEqual("Clean Code, 2nd edition", _fakeService.Books[0].Title);
}
// new
[TestMethod]
public async Task Update_UnknownId_ReturnsNotFound()
{
var result = await _controller.Update(999, new Book { Title = "Clean Code" });
Assert.IsInstanceOfType<NotFoundResult>(result);
}
// new
[TestMethod]
public async Task Delete_ExistingBook_ReturnsNoContentAndRemovesIt()
{
_fakeService.Books.Add(new Book { Id = 3, Title = "Clean Code" });
var result = await _controller.Delete(3);
Assert.IsInstanceOfType<NoContentResult>(result);
Assert.IsEmpty(_fakeService.Books);
}
// new
[TestMethod]
public async Task Delete_UnknownId_ReturnsNotFound()
{
var result = await _controller.Delete(999);
Assert.IsInstanceOfType<NotFoundResult>(result);
}
}
- The result is checked directly, as
result, not asresult.Result. AnIActionResultis the answer itself, not a wrapper around a book, soAssert.IsInstanceOfType<NoContentResult>(result)says it plainly: the answer is a 204. - Each endpoint gets two promises: the answer for a book that exists (204, and the service really was called), and the answer for one that does not (404). Those are the two decisions we wrote in this lesson.
- The 400s are not here, for the reason given when creating a book: they happen in front of the controller method. The rules have their tests there, and we saw the 400s by hand above.
Run all tests from the solution folder:
dotnet test
Passed! - Failed: 0, Passed: 13, Skipped: 0, Total: 13, Duration: 143 ms - BookShop.Api.Tests.dll (net10.0)
Passed! - Failed: 0, Passed: 15, Skipped: 0, Total: 15, Duration: 1 s - BookShop.Data.Tests.dll (net10.0)
Twenty-eight tests, two projects. (The order and the durations may differ.)
In the test page
The third step of the loop. Open /scalar. The menu now lists all five endpoints. Open PUT /books/{id}: it has an id box and a body editor. Type an id, paste the JSON of a whole book, and press Test Request. Look for the 204, then use GET /books/{id} to see the change. Try an unknown id and an empty title. Then try DELETE /books/{id} the same way, twice, and compare the two answers. The page should list the answers 204, 400 and 404 for PUT, and 204 and 404 for DELETE. We checked the same list in /openapi/v1.json.
Where we ended
Compare with your solution. The service has all four jobs, the controller has all five endpoints:
BookShop/
BookShop.slnx
Directory.Build.props
Directory.Packages.props
src/
BookShop.Api/
Program.cs
GlobalExceptionHandler.cs
Controllers/
BooksController.cs GetAll, GetById, Create, Update, Delete
BookShop.Data/
Book.cs
BookShopDbContext.cs
IBookService.cs plus UpdateAsync and DeleteAsync
BookService.cs plus UpdateAsync and DeleteAsync
Migrations/
tests/
BookShop.Data.Tests/
BookServiceTests.cs eleven tests, five of them new
BookValidationTests.cs
BookShop.Api.Tests/
FakeBookService.cs plus UpdateAsync and DeleteAsync
FakeLogger.cs
BooksControllerTests.cs ten tests, five of them new
GlobalExceptionHandlerTests.cs
Recap
- PUT replaces the whole thing at an address, so the client sends every field. PATCH sends only some fields, and is left out here because a plain
Bookcannot tell "not sent" from "empty". UpdateAsyncandDeleteAsyncreturn abool:falsemeans "no such book". The controller turnsfalseinto 404, and success into 204 No Content.IActionResultis the return type when an endpoint sends no data.NoContent()builds the 204.- The checks of
[ApiController]come first: a bad id or a body that breaks the rules is a 400 before the service is asked. The address chooses the book, and anidin the body is ignored. - DELETE is idempotent: repeating it leaves the table the same, though the second answer is a 404.
- The context remembers the objects it loaded. A test that proves a save must call
ChangeTracker.Clear()before reading back, or it can pass without the save. - The books endpoints still take and return the
Bookentity, with its shortcuts. A later lesson fixes them.
Questions to think about
These have no answers here, on purpose. Try them from memory first.
- What is the difference between PUT and PATCH?
- Why does the service return
falseand not decide on a 404 itself? - Why do these two endpoints answer 204 and not 200?
- Why must a test that checks a save clear what the context remembers?
- A client asks to sell more copies than the shelf holds. Which status fits, and what should the answer say? (The next lesson answers this.)
Next lesson: business rules and meaningful errors. The shop's own rules, such as not selling more copies than the shelf holds, and the errors that explain them.