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:

    1. Meet PUT, which replaces a whole book, and see why PATCH is left out.
    2. Add UpdateAsync and DeleteAsync to the service, and to the fake.
    3. Add the two endpoints, with an answer that has no body.
    4. Run them, including the cases that go wrong.
    5. Test the service and the controller, and meet a test trap about what EF Core remembers.
    6. 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.

    PUT replaces all five fields, PATCH changes only some PUT /books/2: all five fields are sent, all five are replaced title author price stock category PATCH /books/2: only price is sent, only price changes title author price stock category Pink: written by the request. Grey: left as it was.

    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 say false.
    • 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 the UPDATE. (When the loaded book is changed, the same call that saved a new book now saves the change.)
    • Id is not copied. The address chose the book, and the body cannot move it to another row. If the body carries an id, 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 PUT simply 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 in GetById.
    • Update(int id, Book book) has both kinds of input: id comes from the address, book comes from the body.
    • IActionResult is 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. IActionResult simply means "some answer".
    • NoContent() builds a 204 No Content: it worked, and there is nothing to send. Why not 200 with the updated book? Some APIs do. We chose 204 because the client just sent the book and does not need it back, and a DELETE has no book left to send. A client that wants to see the result can GET it.
    • The validation rules apply to PUT too, because the body is the same Book. A PUT with an empty title never reaches Update.
    • The three [ProducesResponseType] lines tell the description about the answers, as before: all of them, or the page shows only what it can guess.
    Who decides which answer Request [ApiController] id and body checked Service found it? 204 No Content 400 Bad Request 404 Not Found fails false true

    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. PUT takes the Book entity, so a client can also set Stock to 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"
    }
    
    The same DELETE twice Client API DELETE /books/3 204: it was there, now gone DELETE /books/3 404: it is gone already

    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.

    Why the test clears the change tracker Without Clear Update changes the remembered book Read finds it in the context's memory test passes even if never saved With Clear Update changes and saves Clear() forget everything Read goes to the database test shows the truth Simplified: the context remembers books by their id, but the idea is the one shown.

    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 as result.Result. An IActionResult is the answer itself, not a wrapper around a book, so Assert.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 Book cannot tell "not sent" from "empty".
    • UpdateAsync and DeleteAsync return a bool: false means "no such book". The controller turns false into 404, and success into 204 No Content.
    • IActionResult is 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 an id in 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 Book entity, with its shortcuts. A later lesson fixes them.

    Questions to think about

    These have no answers here, on purpose. Try them from memory first.

    1. What is the difference between PUT and PATCH?
    2. Why does the service return false and not decide on a 404 itself?
    3. Why do these two endpoints answer 204 and not 200?
    4. Why must a test that checks a save clear what the context remembers?
    5. 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.

    ← Create a Book: POSTBusiness Rules and Meaningful Errors →