Skip to content

CRUD Testing

Concept

CRUD testing verifies the full data lifecycle: Create, Read, Update, Delete. The key insight is to verify data state after every operation, not only at creation.

Create → Read (verify) → Update → Read (verify) → Delete → Read (verify not found)

Why CRUD Testing Matters

Common bugs CRUD testing catches:

  • Delete works in UI but record stays in database
  • Update changes one field and silently nulls another
  • Create succeeds but Read returns stale data (cache issue)
  • Duplicate create allowed when it shouldn't be
  • Delete of non-existent record causes crash instead of graceful error

Full Lifecycle Example

import pytest


class UserStorage:
    def __init__(self) -> None:
        self._users: dict[str, dict[str, str]] = {}

    def create(self, user_id: str, name: str) -> dict[str, str]:
        if user_id in self._users:
            raise ValueError("User already exists")
        self._users[user_id] = {"id": user_id, "name": name}
        return self._users[user_id]

    def read(self, user_id: str) -> dict[str, str]:
        if user_id not in self._users:
            raise KeyError("User not found")
        return self._users[user_id]

    def update(self, user_id: str, name: str) -> dict[str, str]:
        if user_id not in self._users:
            raise KeyError("User not found")
        self._users[user_id]["name"] = name
        return self._users[user_id]

    def delete(self, user_id: str) -> bool:
        if user_id not in self._users:
            raise KeyError("User not found")
        del self._users[user_id]
        return True


class TestCRUD:
    def setup_method(self) -> None:
        self.storage = UserStorage()

    def test_create_and_read(self) -> None:
        self.storage.create("1", "Alice")
        assert self.storage.read("1")["name"] == "Alice"

    def test_update_changes_data(self) -> None:
        self.storage.create("1", "Alice")
        self.storage.update("1", "Bob")
        assert self.storage.read("1")["name"] == "Bob"

    def test_delete_removes_record(self) -> None:
        self.storage.create("1", "Alice")
        self.storage.delete("1")
        with pytest.raises(KeyError):
            self.storage.read("1")

    def test_create_duplicate_raises(self) -> None:
        self.storage.create("1", "Alice")
        with pytest.raises(ValueError):
            self.storage.create("1", "Alice")

    def test_full_lifecycle(self) -> None:
        self.storage.create("1", "Alice")
        assert self.storage.read("1")["name"] == "Alice"
        self.storage.update("1", "Bob")
        assert self.storage.read("1")["name"] == "Bob"
        self.storage.delete("1")
        with pytest.raises(KeyError):
            self.storage.read("1")

CRUD Test Checklist

Operation What to Verify
Create Record exists after creation; all fields stored correctly
Create Duplicate creation is rejected with correct error
Read Returns correct data; non-existent ID raises correct error
Update Only updated fields change; other fields unchanged
Update Update on non-existent ID raises correct error
Delete Record no longer accessible after deletion
Delete Delete on non-existent ID raises correct error
Lifecycle Full C→R→U→R→D sequence produces consistent state

Isolation Requirement

Each test must start with a clean state. Never share storage instances between tests.

# Good: fresh instance per test
def setup_method(self) -> None:
    self.storage = UserStorage()

# Bad: shared state causes test pollution
storage = UserStorage()  # class-level, shared between all tests

CRUD in API Testing

For HTTP APIs, the same pattern applies:

POST /users       → assert 201, body has id and name
GET  /users/{id}  → assert 200, correct data
PUT  /users/{id}  → assert 200, updated fields
GET  /users/{id}  → assert updated data (not cached old data)
DELETE /users/{id} → assert 204
GET  /users/{id}  → assert 404

Risks

Risk Description Mitigation
Read-only after create Skipping re-read after update Always read after every write
Shared test data One test changes data another expects Unique IDs per test; setup_method
Soft delete not verified Record marked deleted but still returned Test that soft-deleted record is excluded from reads