Topic 4: Testing
19 min read·22 Sept 2026
We test at three levels, cheapest first: the store with no protocol at all, the server through an in-memory client, and the real entry point over stdio exactly once. Then we look at the Inspector for the interactive debugging tests cannot do, and measure what each level costs.
Unit testing the logic without the protocol layer
Because every rule lives in NoteStore, most of the testing needs no MCP. tmp_path is pytest's built-in fixture that gives each test a fresh empty folder, so tests can create notes without touching notes/.
python
"""Unit tests for NoteStore: plain Python, no MCP, no event loop."""
from __future__ import annotations
import shutil
from datetime import date
from pathlib import Path
import pytest
from notes_assistant.store import (
InvalidNote,
NoteError,
NoteExists,
NoteNotFound,
NoteStore,
slugify,
validate_note_id,
)
SAMPLE_NOTES = Path(__file__).resolve().parent.parent / "notes"
@pytest.fixture
def store(tmp_path: Path) -> NoteStore:
"""A private copy of the 8 sample notes, so tests can write without touching notes/."""
shutil.copytree(SAMPLE_NOTES, tmp_path / "notes")
return NoteStore(tmp_path / "notes")
def test_missing_folder_is_rejected(tmp_path: Path) -> None:
with pytest.raises(NoteError, match="Notes folder not found"):
NoteStore(tmp_path / "nope")
def test_list_notes_is_sorted_and_complete(store: NoteStore) -> None:
ids = [n.note_id for n in store.list_notes()]
assert len(ids) == 8
assert ids == sorted(ids)
def test_get_parses_frontmatter(store: NoteStore) -> None:
note = store.get("sleep-and-memory")
assert note.title == "Sleep and memory consolidation"
assert note.tags == ("sleep", "memory", "neuroscience")
assert note.created == "2026-03-02"
assert not note.body.startswith("---")
def test_get_missing_note(store: NoteStore) -> None:
with pytest.raises(NoteNotFound, match="no-such-note"):
store.get("no-such-note")
@pytest.mark.parametrize("bad_id", ["../secrets", "Sleep", "", "a" * 65, "-leading-hyphen", "notes/x"])
def test_validate_note_id_rejects_unsafe_ids(bad_id: str) -> None:
with pytest.raises(InvalidNote):
validate_note_id(bad_id)
def test_search_ranks_title_matches_first(store: NoteStore) -> None:
hits = store.search("sleep memory")
assert hits[0].note_id == "sleep-and-memory"
assert [h.score for h in hits] == sorted((h.score for h in hits), reverse=True)
def test_search_filters_by_tag(store: NoteStore) -> None:
assert [h.note_id for h in store.search("sleep", tag="meeting")] == ["lab-sync-2026-09-02"]
def test_search_needs_a_real_word(store: NoteStore) -> None:
with pytest.raises(InvalidNote):
store.search("???")
def test_create_writes_frontmatter(store: NoteStore) -> None:
note = store.create("Napping and recall", "Short naps helped recall in pilot 3.", ["Sleep", "pilot study"])
assert note.note_id == "napping-and-recall"
assert note.tags == ("sleep", "pilot-study")
assert note.created == date.today().isoformat()
text = (store.root / "napping-and-recall.md").read_text(encoding="utf-8")
assert text.startswith("---\ntitle: Napping and recall\n")
def test_create_never_overwrites(store: NoteStore) -> None:
with pytest.raises(NoteExists):
store.create("Deep work", "Would clobber deep-work.md")
@pytest.mark.parametrize(("title", "body"), [("", "body"), ("x" * 121, "body"), ("Fine", " "), ("!!!", "body")])
def test_create_rejects_bad_input(store: NoteStore, title: str, body: str) -> None:
with pytest.raises(InvalidNote):
store.create(title, body)
def test_slugify() -> None:
assert slugify("Lab sync, 2 September 2026") == "lab-sync-2-september-2026"Code explained
- In simple words: plain function calls against a private copy of the notes, checking each rule the server relies on.
- What happens:
storefixture: copies the eight sample notes intotmp_pathand returns aNoteStoreover the copy. Every test gets its own copy, so order does not matter.test_missing_folder_is_rejected: the store refuses to start on a folder that does not exist, which is what makes a wrongNOTES_DIRfail at startup.test_list_notes_is_sorted_and_complete,test_get_parses_frontmatter,test_get_missing_note: reading behavior, including that the frontmatter is stripped from the body.test_validate_note_id_rejects_unsafe_ids: one test, six cases viaparametrize, including../secretsandnotes/x. This is the second traversal defense behind the SDK's URI matching.- Search tests: ranking (title words weigh more, so
sleep-and-memorywins for "sleep memory"), tag filtering, and the "at least one real word" rule that becomes aToolErrorin the server. - Create tests: frontmatter is written, tags are slugified, today's date is recorded, existing notes are never overwritten, and four kinds of bad input are rejected.
test_slugify: the id a title turns into, since ids are what the model cites.
- Comes out (
python -m pytest tests/test_store.py -q):text.................... [100%] 20 passed in 0.05sTwenty tests in about 0.06 s. No event loop, no subprocess, no JSON-RPC.