Verdict
Yes
Test each create, read, update and delete path you write with an integration test against a real database that writes a record, reads it back and checks every field; do not unit test CRUD code against a mocked repository.
Why
- Blast radiususers
- Change frequencyregularly
- Detectabilityeventually
- Reversibilitywith-effort
- Test costmoderate
Test the CRUD code you write, against a real database. The typical case is a backend with create, read, update and delete endpoints for customers, where your code maps request fields onto an ORM entity. Blast radius is users and Change frequency is regularly, because customers lose data when a write breaks, and resources gain fields with most features. Detectability is eventually, because an update that drops a field returns 200 and a plausible record. Reversibility is with-effort, because damaged rows need a repair script, and Test cost is moderate, because each test needs a local database, so rule R11 gives Test.
| When | Decision | Why |
|---|---|---|
| The repository only inherits save, findById and delete from a framework base such as Spring Data CrudRepository | Do not test the inherited methods; test the code that calls them | Change frequency falls to rarely, Detectability to immediately and Reversibility to trivial, because the framework's own suite covers these methods |
| Integration tests already create, read, update and delete each resource with a distinct value in every field | Do not add unit tests with a mocked repository | Detectability moves to immediately and Reversibility to trivial, because a broken operation fails those tests in CI before merge |
| An update or delete changes an invoice amount or an account balance | Test mandatory: the main path, every known failure and the boundary amounts | Blast radius rises to money and Reversibility to costly |
| Read, update and delete load a record by an ID from the URL, and users may touch only their own records | Test mandatory, including requests for another user's record that must fail | Blast radius rises to safety-or-legal and Detectability to never, because a missing ownership check raises no error |
| Only staff edit reference data, through a scaffolded admin such as Django admin | Do not test the admin screens; fix a broken form when staff report it | Blast radius falls to internal, Change frequency to rarely and Detectability to same-day |
| Tests can reach the legacy database only through a shared staging copy | Test it differently: a nightly query counts records with empty required columns and alerts above zero | Test cost rises to heavy while Detectability stays eventually and Reversibility with-effort |
What breaks if you don't test
CRUD code breaks in the mapping between the request and the row, and a mapping break throws no error. A new column reaches the create path but not the update mapper, or an update saves a new object built from the request and empties every field the client did not send. Users notice days later, when a value they saved is gone.
What you lose if you over-test
The common over-test is a unit test per operation with a mocked repository, asserting that create() calls save() once. It passes when the SQL or the column mapping is wrong, and it fails when you rename a repository method. A test that findById() returns a saved row checks Spring Data or Django instead of your code.
How to test
Write integration tests that run each operation through the endpoint or the service, against the database engine production runs. For create, send a distinct value in every field and read the row back. For update, change one field and assert that the others kept their values. For delete, assert that the record and its child rows are gone. Django's test client and Spring Boot's test slices provide the setup. For records that belong to users, add the ownership tests from the OWASP IDOR cheat sheet.
When the answer changes
- Records have owners, and operations take an ID from the URL.
- An operation changes money, such as a balance.
Real incident + Code example
The edits that erased gate codes
On a field-service scheduling backend I worked on, the Django view behind PATCH /api/customers/<id>/ built a Customer from the request body with the existing ID and called save(). Django's save() on an object with a primary key updates every column, so fields missing from the body got default values. Our update test sent the web form's full payload and passed. When the Android app added an edit screen that sent only the changed phone number, each edit emptied the customer's access notes, such as gate codes. Technicians met locked gates for 11 days, and we restored 460 notes from nightly backups. The regression test sends one field and checks the others:
import pytest
from customers.models import Customer
@pytest.mark.django_db
def test_patch_keeps_fields_it_did_not_send(client):
customer = Customer.objects.create(
name="Ana Ruiz", phone="+34 600 111 222", access_notes="Gate code 4812"
)
response = client.patch(
f"/api/customers/{customer.id}/",
{"phone": "+34 600 333 444"},
content_type="application/json",
)
assert response.status_code == 200
customer.refresh_from_db()
assert customer.phone == "+34 600 333 444"
assert customer.access_notes == "Gate code 4812"
The fix applies only the fields in the request to the stored record.
Related questions
FAQ
- Should I write unit tests for CRUD operations?
No, test CRUD operations with integration tests against a real database instead of unit tests with a mocked repository. A mocked repository cannot see a wrong column mapping.
- Should I write unit tests for CRUD operations when I already have integration tests?
No, integration tests that run each operation with a distinct value in every field already catch what CRUD unit tests would. Unit test only logic inside an operation, such as a price calculation.
- Should I test repository save and find methods?
No, do not test the save, find and delete methods that Spring Data or Django provides; their own suites cover them. Test each query you write yourself with one database test.
- Should CRUD tests use a real database?
Yes, CRUD tests should use the database engine production runs. SQLite ignores
VARCHARlengths, so a string that PostgreSQL rejects passes a test on SQLite.- Should I test delete operations?
Yes, test each delete you write with one integration test that checks the record and its child rows are gone. For owned records, add a test that deleting another user's record fails.