Authoring Exercises
This guide explains how to write exercises and unit tests for notebook-ta.
Exercise Structure
Each exercise is defined in exercises.toml under [exercises.<id>]:
[exercises.ex1]
name = "Add two numbers"
statement = "Write a function `add(a, b)` that returns the sum of two numbers."
additional_info = "No imports are needed."
The id (here ex1) is the stable identifier used in the %%notebook_ta cell magic line.
The optional name is an editable display name used by the benchmarking interface; changing it
does not break saved solutions or historical benchmark records.
statement is optional — see Embedding statements in the notebook for an alternative that avoids duplicating the exercise description.
Writing Unit Tests
Tests are declared as TOML array tables: [[exercises.<id>.tests]].
Inline Tests
Use the code field to write a Python function directly in the TOML file:
[[exercises.ex1.tests]]
name = "add(2, 3) == 5"
code = """
def test_add(add):
result = add(2, 3)
return result == 5, f"Expected 5, got {result}"
"""
The runner inspects the test function’s signature and resolves every parameter name in the
student’s IPython namespace. In this example, the add parameter receives the object stored under
the name add. Multiple parameters are resolved independently in the same way.
Resolution happens before the test is called. If any parameter name is absent from the student’s
namespace, the test is not executed and is reported as failed with a message identifying the
missing name. The reserved student_globals parameter follows the explicit export rules in
Passing student symbols to tests instead.
It must return either:
A
bool(True= pass,False= fail)A
tuple[bool, str]where the string is a human-readable message
Any text printed to stdout is also captured and included in the message. Common ANSI SGR color and text-style sequences (including standard and bright colors, bold, and underline) are rendered in the notebook output, so existing terminal-style custom test reports remain readable.
External Tests
For complex tests, reference a function in an importable Python module:
[[exercises.ex3.tests]]
name = "Performance test"
module = "course_tests.ex3"
function = "test_performance"
For example, course_tests/ex3.py could contain:
def test_performance(build_index):
index = build_index(["alpha", "beta"])
return len(index) == 2, "Expected an index containing both values."
The module must be importable from the notebook’s working directory or PYTHONPATH. After loading
the configured function, the runner resolves its parameters exactly as it does for an inline test:
each parameter name is looked up in the student’s IPython namespace. If any name is missing, the
function is not called and the test is reported as failed with the missing name. The reserved
student_globals parameter uses the shared rules below.
Passing student symbols to tests
The following rules apply to both inline and external tests. Put student_symbols or
export_student_globals in the test’s TOML table alongside either code or module/function.
Named parameters
Named parameters are the preferred way to pass student definitions to a test. Only the requested
objects are serialized for the isolated test process. For example, a function declared as
def test_result(parse, render): ... receives the student’s parse and render objects. The test
fails before invocation if either name is not defined.
Exporting multiple symbols as a dictionary
When a test needs a dictionary of several student definitions, declare student_symbols. The
runner exports only those names and passes the resulting dictionary as student_globals:
[[exercises.ex2.tests]]
name = "Both functions are defined"
student_symbols = ["add", "mul"]
code = """
def test_both_defined(student_globals):
has_add = "add" in student_globals and callable(student_globals["add"])
has_mul = "mul" in student_globals and callable(student_globals["mul"])
return has_add and has_mul, "Expected both add() and mul() to be defined."
"""
A missing selected symbol is reported as a failed test. A test that declares a
student_globals parameter without either export option also fails with a configuration message.
The full IPython namespace can be exported explicitly with
export_student_globals = true, but this is strongly discouraged. Notebook namespaces commonly
contain large objects, open resources, module state, or objects that cloudpickle cannot serialize.
Exporting all of them can therefore make tests slow or cause process preparation to fail even when
the objects relevant to the test are valid. Use named parameters or student_symbols whenever
possible.
Unit Test Timeouts
Each unit test is cancelled if it runs longer than the global unit_test_timeout from
global_config.toml. The default is 5 seconds. A timeout is reported as a failed test in the
notebook and in benchmark runs, and the timeout message is included in the LLM prompt.
Override the timeout for one exercise with unit_test_timeout:
[exercises.ex_slow]
statement = "Implement a function that handles a large input."
unit_test_timeout = 15.0
Exercise-Level Prompt Overrides
You can override the global prompts for a specific exercise:
[exercises.ex_hard]
statement = "Implement Dijkstra's algorithm."
prompt_on_success = "Excellent! Analyse the time and space complexity of this graph algorithm."
prompt_on_failure = "Graph algorithms can be tricky. Think about the data structures you need. When the student asks for hints, escalate guidance gradually."
Embedding Statements in the Notebook
To avoid duplicating the exercise description in both the notebook and exercises.toml, you can
omit statement from the TOML and embed it directly in a notebook markdown cell using a
<div id="<exercise-id>"> block:
<div id="ex1">
## Exercise 1 — Add two numbers
Write a function `add(a, b)` that returns the sum of `a` and `b`.
Example:
```python
add(2, 3) # → 5
```
</div>
The div renders normally in Jupyter and its inner content is used as the statement sent to the LLM.
Multiple markdown cells with the same id are concatenated in document order.
To use this feature, pass notebook_path= to notebook_ta.load():
import notebook_ta
notebook_ta.load(
"global_config.toml",
"exercises.toml",
notebook_path="my_notebook.ipynb", # path to the current notebook
)
If notebook_path is not provided, notebook-ta will try to detect the notebook path
automatically. Pass it explicitly when auto-detection
fails or for reproducibility.
Error handling — if
statementis absent from both the TOML and the notebook,load()raises aConfigurationErroridentifying the affected exercise.
Complete Example
[exercises.reverse]
statement = """
Write a function `reverse_list(lst)` that returns a new list
with the elements in reverse order.
"""
additional_info = "Do not use lst[::-1] or lst.reverse()."
[[exercises.reverse.tests]]
name = "reverse_list([1,2,3]) == [3,2,1]"
code = """
def test_basic(reverse_list):
return reverse_list([1,2,3]) == [3,2,1], "Basic reversal failed"
"""
[[exercises.reverse.tests]]
name = "Empty list returns empty list"
code = """
def test_empty(reverse_list):
return reverse_list([]) == [], "Empty list should return []"
"""
[[exercises.reverse.tests]]
name = "Original list not modified"
code = """
def test_no_mutation(reverse_list):
lst = [1, 2, 3]
copy = list(lst)
reverse_list(lst)
return lst == copy, "The original list was mutated!"
"""
Tips
Keep test names short and descriptive — they appear in the notebook output.
Return a meaningful message from your tests to help students understand failures.
Use
print()inside test functions to emit additional diagnostic information.Prefer named test parameters; use
student_symbolsonly when the test needs a dictionary.External module tests are useful for complex or reusable test logic shared across exercises.