MicroPython for Microcontrollers Module 12 – Testing with the unix port
Fakes for machine, time and network
Test MicroPython board code on a computer with fakes for machine, time and network: hardware passed in, a clock that wraps, buses and Wi-Fi that fail on cue.
What you will learn
- Replace hardware modules with fakes in host tests by passing objects in rather than shadowing modules
- Build a fake clock whose ticks wrap at 2**30, and use it to catch a wrap bug in milliseconds
- Script bus faults such as OSError with EIO and ENODEV in a fake I2C, and test a driver's retries
- Simulate Wi-Fi joins with a fake WLAN that returns each port's documented status codes
- Check a fake against the real module with one contract test that both must pass
Before you start
What you’ll learn · Before you start
What you will learn
- Replace hardware modules with fakes in host tests by passing objects in rather than shadowing modules
- Build a fake clock whose ticks wrap at 2**30, and use it to catch a wrap bug in milliseconds
- Script bus faults such as OSError with EIO and ENODEV in a fake I2C, and test a driver's retries
- Simulate Wi-Fi joins with a fake WLAN that returns each port's documented status codes
- Check a fake against the real module with one contract test that both must pass
Before you start
On this page
A greenhouse controller switches a pump with machine.Pin, reads the soil through machine.ADC and times the pump
with time.ticks_ms(), and it has a bug that shows only when the tick counter wraps, once every 12.4 days. Nobody
waits 12 days beside a board for a test; with a fake clock: An object with ticks_ms(), ticks_add() and ticks_diff() that a test moves forward by hand. It keeps ticks modulo 2**30 like a 32-bit board, so it can start just before the wrap., the wrap arrives in the
first millisecond.
This lesson builds fakes for machine, time and network, small objects with the same methods as the real ones, and the rules that keep them honest. The programs run on CPython and are written in the part of Python that MicroPython shares, as host tests are.
Pass the hardware in, and a fake can take its place
There are two ways to put a fake: A small stand-in for a module or an object, used in tests: a fake machine module whose I2C class returns prepared bytes lets a driver run without the hardware. More in “Imports, modules and packages on a device” under board code. One is to shadow the module: a file named
machine.py earlier on the path replaces the built-in machine on the unix port and on a board, because machine is
an extensible module, and CPython has no machine of its own. Shadowing is fragile, though. A file named time.py
wins on MicroPython and loses on CPython, whose time is built into the interpreter, and a fake file that reaches a
board replaces the real module there. The other way is to pass the hardware in, the
dependency injection: Giving an object the things it works with, such as a bus, as arguments instead of letting it create them. A driver that takes its bus this way runs on any bus, a fake one in a test included. More in “Write a reusable device driver class” of the drivers lesson: the controller receives its pin,
its ADC and its clock as arguments and creates none of them, so on the board main.py passes Pin(15, Pin.OUT),
ADC(26) and the time module itself, and a test passes fakes. Follow the controller through both:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- On the board. main.py creates the pump's pin, the sensor's ADC, and passes them to Greenhouse with the time module itself as the clock, since time has ticks_ms() and ticks_diff().
- The calls it makes. Each step reads the soil with read_u16(), reads the time with ticks_ms(), and switches the pump with value(). Those three calls are everything the controller needs from the hardware.
- In a test. A test passes FakePin, FakeADC and FakeClock instead. They have the same methods, so the controller runs unchanged, on a computer, with no board attached.
- Time on demand. The fake clock starts one second before the tick counter wraps, and advance() moves it on at once, so 32 seconds of pumping take no time and the wrap arrives on the first step.
- What the test reads. The test reads the fake pin's history, 0, then 1, then 0, and fails when the pump never went off, which is how the wrap bug of the next section shows itself.
Fakes for machine are the first kind a board’s tests need: a pin that remembers its levels, an ADC that returns prepared readings, a bus that answers from a table. Each copies only the real module’s methods that the code needs and adds none of its own, so a call that the board’s module does not offer fails in the test as well. Time and network follow the same rule. A fake network module and WLAN return the documented status codes in a scripted order, a fake I2C bus raises the board’s errors on chosen calls, and one contract test: A test of what code relies on from a module, such as a bus that returns bytes of the length asked for, run against the fake on a computer and against the real module on a board., run on the fake and on the real module, keeps each fake true to the board.
Checkpoint
Check: Passing hardware in instead of shadowing modules
A fake clock brings the tick wrap into the test
On the rp2 and esp32 ports the tick counters wrap at 2³⁰, and time.ticks_diff() gives the right difference across
the wrap while the two values are less than half that period apart. The unix port of a 64-bit computer wraps at 2⁶²,
so a test there never meets the wrap a Pico meets every 12.4 days. A fake clock fixes both: it keeps ticks modulo
2³⁰, has ticks_ms(), ticks_add() and ticks_diff(), and advance() moves it on at once. Step through the
controller’s bug, the elapsed time computed as plain subtraction, with the start one second before the wrap:
Program · fakes/wrap_bug.py
PERIOD = 1 << 30
HALF = PERIOD // 2
def diff(a, b):
return (a - b + HALF) % PERIOD - HALF
started = PERIOD - 1000
for after in (10_000, 31_000):
now = (started + after) % PERIOD
wrong = now - started
right = diff(now, started)
print(after, wrong, right) Variables
- just taken by the loop
- changed in this step
- passed by the loop
- read next
- where the loop stopped
Printed so far
Nothing printed yet10000 -1073731824 10000 31000 -1073710824 31000
Every step was recorded from a run with Python 3.14.8 on macOS 26 arm64.
The drawing shows the variables when the program ends, and the program's 16 steps are listed below.
Change the program and run it here. Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Every step as text
- Start. No line has run yet, so there are no variables. Python starts with line 1.
- Line 1.
PERIOD = 1 << 30setsPERIODto1073741824. - Line 2.
HALF = PERIOD // 2setsHALFto536870912. - Line 5.
def diff(a, b):makes the functiondiff. Its body runs only when it is called. - Line 9.
started = PERIOD - 1000setsstartedto1073740824. started is 1,073,740,824: one second before the counter wraps to 0, as a board reaches every 12.4 days. - Line 10.
for after in (10_000, 31_000):takes the item at position 0 of(10_000, 31_000):afteris10000. - Line 11.
now = (started + after) % PERIODsetsnowto9000. - Line 12.
wrong = now - startedsetswrongto-1073731824. The bug Ten seconds later now is 9,000, after the wrap, so now - started is a huge negative number. A check of wrong >= 30_000 is never true, and the pump would never stop. - Line 13.
right = diff(now, started)setsrightto10000. The fix ticks_diff() shifts by half the period, takes the remainder and shifts back, so it gives 10,000, the time that passed. - Line 14.
print(after, wrong, right)prints10000 -1073731824 10000. - Line 10.
for after in (10_000, 31_000):takes the item at position 1 of(10_000, 31_000):afteris31000. - Line 11.
now = (started + after) % PERIODsetsnowto30000(it was9000). - Line 12.
wrong = now - startedsetswrongto-1073710824(it was-1073731824). - Line 13.
right = diff(now, started)setsrightto31000(it was10000). - Line 14.
print(after, wrong, right)prints31000 -1073710824 31000. - Line 10.
for after in (10_000, 31_000):finds no item at position 2 of(10_000, 31_000)(it has 2 items), so the loop ends. That was the last line, so the program ends. A test on the unix port of a 64-bit computer never sees this, because its ticks wrap at 2**62. A fake clock that starts just before the wrap shows it in milliseconds.
The program below runs three tests on two versions of the controller, the right one and the one that subtracts, with the fake clock started at 0 and at one second before the wrap:
fakes/test_greenhouse.py
"""Three tests of the greenhouse controller, run on both versions, with the fake clock started at 0 and at one second
before the tick counter wraps. A tiny runner prints each result."""
from fakes import PERIOD, FakeADC, FakeClock, FakePin
from greenhouse import DRY, Greenhouse, GreenhouseWithBug
def run_for(controller, clock, ms, every=1000):
for _ in range(ms // every):
controller.step()
clock.advance(every)
def test_dry_soil_starts_pump(cls, start):
pump, clock = FakePin(), FakeClock(start)
cls(pump, FakeADC([DRY + 5000]), clock).step()
assert pump.value() == 1
def test_wet_soil_stops_pump(cls, start):
pump, clock = FakePin(), FakeClock(start)
g = cls(pump, FakeADC([DRY + 5000, DRY + 5000, DRY - 4000]), clock)
run_for(g, clock, 3000)
assert pump.history == [0, 1, 0], pump.history
def test_pump_stops_after_30_s(cls, start):
pump, clock = FakePin(), FakeClock(start)
run_for(cls(pump, FakeADC([DRY + 5000]), clock), clock, 32_000)
assert pump.history[:3] == [0, 1, 0], "the pump still ran after 32 s"
for cls in (Greenhouse, GreenhouseWithBug):
for start in (0, PERIOD - 1000):
results = []
for test in (test_dry_soil_starts_pump, test_wet_soil_stops_pump, test_pump_stops_after_30_s):
try:
test(cls, start)
results.append("ok")
except AssertionError:
results.append("FAIL " + test.__name__[5:])
print("%-17s clock from %10d: %s" % (cls.__name__, start, ", ".join(results))) Output
Greenhouse clock from 0: ok, ok, ok Greenhouse clock from 1073740824: ok, ok, ok GreenhouseWithBug clock from 0: ok, ok, ok GreenhouseWithBug clock from 1073740824: ok, ok, FAIL pump_stops_after_30_s
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 test_greenhouse.py
fakes/greenhouse.py
"""A greenhouse pump controller, as it runs on a board. It creates no hardware itself: the pump pin, the soil sensor's
ADC and the clock are passed in. On a board the clock is the time module itself, which has ticks_ms() and
ticks_diff(); in a test it is a fake."""
DRY = 30_000 # read_u16() above this: the soil is dry
MAX_PUMP_MS = 30_000 # never run the pump longer than this in one go
class Greenhouse:
def __init__(self, pump, soil, clock):
self.pump, self.soil, self.clock = pump, soil, clock
self.started = None
def step(self):
now = self.clock.ticks_ms()
if self.started is None:
if self.soil.read_u16() > DRY:
self.pump.value(1)
self.started = now
elif self.clock.ticks_diff(now, self.started) >= MAX_PUMP_MS or self.soil.read_u16() <= DRY:
self.pump.value(0)
self.started = None
class GreenhouseWithBug(Greenhouse):
"""The same controller with the comparison a board meets every 12.4 days: plain subtraction of ticks."""
def step(self):
now = self.clock.ticks_ms()
if self.started is None:
if self.soil.read_u16() > DRY:
self.pump.value(1)
self.started = now
elif now - self.started >= MAX_PUMP_MS or self.soil.read_u16() <= DRY:
self.pump.value(0)
self.started = None fakes/fakes.py
"""Fakes for the three things the greenhouse controller uses: a pin, an ADC and a clock. They copy only the board's
methods that the controller calls and add none of their own, so a call the board does not have fails in the test too.
FakeClock keeps ticks the way MicroPython's 32-bit ports do (extmod/modtime.c and py/mpconfig.h, v1.29.0): they wrap
at 2**30, ticks_add() and ticks_diff() work modulo that period, and ticks_diff() is right while the two values are less
than half a period apart."""
PERIOD = 1 << 30
HALF = PERIOD // 2
class FakePin:
def __init__(self, value=0):
self._value = value
self.history = [value]
def value(self, v=None):
if v is None:
return self._value
self._value = 1 if v else 0
self.history.append(self._value)
class FakeADC:
"""read_u16() returns the readings it was given, one per call, then the last one again."""
def __init__(self, readings):
self._readings = list(readings)
def read_u16(self):
return self._readings.pop(0) if len(self._readings) > 1 else self._readings[0]
class FakeClock:
def __init__(self, start=0):
self._now = start % PERIOD
def ticks_ms(self):
return self._now
def ticks_add(self, ticks, delta):
return (ticks + delta) % PERIOD
def ticks_diff(self, ticks1, ticks2):
return ((ticks1 - ticks2 + HALF) % PERIOD) - HALF
def advance(self, ms):
self._now = (self._now + ms) % PERIOD Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
From 0 both versions passed everything, which is what a test with the real clock of a computer sees. Started just before the wrap, the subtracting version kept the pump running past 30 s, and only that test failed: the bug a board meets after 12 days, found in milliseconds.
Key idea
Give board code its time as an object, and let tests start that clock just before 2³⁰: a fake clock turns a bug that takes 12.4 days to appear into one that a test finds at once.
Checkpoint
Check: A fake clock that wraps
Check yourself
Write the three fakes the greenhouse tests used, then work through the self-check on passing hardware in and on fake clocks.
Exercise · Medium · Python
Fakes for a pin, an ADC and a clock that wraps
Write three fakes in myfakes.py for host tests of board code. Each has only the methods listed.
Pin(value=0): value() returns the pin's level as 0 or 1; value(v) sets it to 1 when v is true and to 0 otherwise. history is a list of every level the pin has had, starting with the first.
ADC(readings): read_u16() returns the readings in order, one per call, and then the last one at every further call. Every reading must be an integer from 0 to 65535: the constructor raises ValueError otherwise, and for an empty list.
Clock(start=0): ticks_ms() returns the current value, which starts at start modulo 2**30; advance(ms) moves it on, wrapping at 2**30. ticks_add(t, delta) returns t + delta modulo 2**30, and raises OverflowError when delta is not strictly between -2**29 and 2**29. ticks_diff(a, b) returns a - b as a signed number between -2**29 and 2**29 - 1, so it is right while the two values are less than half the period apart.
Starter code · myfakes.py
PERIOD = 1 << 30
HALF = PERIOD // 2
class Pin:
def __init__(self, value=0):
self.history = [value]
def value(self, v=None):
return 0
class ADC:
def __init__(self, readings):
self.readings = list(readings)
def read_u16(self):
return 0
class Clock:
def __init__(self, start=0):
self.now = start
def ticks_ms(self):
return self.now
def advance(self, ms):
self.now += ms
def ticks_add(self, t, delta):
return t + delta
def ticks_diff(self, a, b):
return a - b The sample tests · test_myfakes.py
from myfakes import ADC, HALF, PERIOD, Clock, Pin
def test_pin_levels_and_history():
"""value(v) stores 0 or 1, value() reads it, history keeps every level"""
pin = Pin()
pin.value(True)
pin.value(0)
pin.value(7)
assert pin.value() == 1
assert pin.history == [0, 1, 0, 1]
def test_adc_repeats_the_last_reading():
"""readings come in order, then the last one again"""
adc = ADC([100, 40000])
assert [adc.read_u16() for _ in range(4)] == [100, 40000, 40000, 40000]
def test_adc_refuses_impossible_readings():
"""65536, -1 and an empty list are not readings an ADC can give"""
for bad in ([65536], [-1], []):
try:
ADC(bad)
except ValueError:
continue
raise AssertionError("ADC(%r) did not raise ValueError" % (bad,))
def test_clock_wraps():
"""advance() wraps at 2**30, like a board's ticks_ms()"""
clock = Clock(PERIOD - 1000)
clock.advance(1500)
assert clock.ticks_ms() == 500
def test_ticks_diff_across_the_wrap():
"""500 ms after a reading taken 1,000 ms before the wrap is 1,500 ms later"""
clock = Clock()
assert clock.ticks_diff(500, PERIOD - 1000) == 1500
assert clock.ticks_diff(PERIOD - 1000, 500) == -1500
def test_ticks_diff_range():
"""half a period apart, ticks_diff() turns negative"""
clock = Clock()
assert clock.ticks_diff(HALF - 1, 0) == HALF - 1
assert clock.ticks_diff(HALF, 0) == -HALF
def test_ticks_add():
"""ticks_add() wraps, and refuses deltas of half a period or more"""
clock = Clock()
assert clock.ticks_add(PERIOD - 10, 30) == 20
assert clock.ticks_add(5, -10) == PERIOD - 5
for bad in (HALF, -HALF):
try:
clock.ticks_add(0, bad)
except OverflowError:
continue
raise AssertionError("ticks_add(0, %d) did not raise OverflowError" % bad) A hint
For ticks_diff, shift by half the period before taking the remainder and back after it: ((a - b + HALF) % PERIOD) - HALF, with PERIOD = 1 << 30 and HALF = PERIOD // 2.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Practice that adapts to you
Practise this lesson
6 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Fake buses that fail on cue, and a driver's retries proved Pro: opens with a Pro, Premium or Ultimate pass
A loose cable makes an I2C transfer fail now and then, and a missing sensor makes every one fail, each with its own error. This part builds a bus that raises those errors on chosen calls and puts a driver’s error handling through seven scripted faults.
In this part:
- Errors scripted per call, and every call recorded
- Seven drills, and the one that the hardware bus changes
1 runnable example (bus_drills.py, fake_i2c.py, humidity.py) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Fake Wi-Fi that follows each port's status codes Pro: opens with a Pro, Premium or Ultimate pass
A join that works at home can wait for ever at a site whose router is slow, and the two ports report the same states with different numbers. This part builds a fake WLAN from the documented codes and runs two joins against six networks on both ports.
In this part:
- A script of states, each port’s own numbers
- Six networks, two joins, and what each number costs
1 runnable example (fake_wlan.py, join.py, wifi_drills.py) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Keeping fakes honest: one contract for the fake and the real module Pro: opens with a Pro, Premium or Ultimate pass
A fake that drifts from the real module lets every test pass and the first board fail. This part writes down what the drivers rely on as a contract, runs it on the fake bus and on four fakes that drifted, and runs the same file on a board.
In this part:
- The contract: what the drivers rely on, written as checks
- The same file on the board, and when to run it
- An exercise: extend the contract to registers and to both missing-device errors
1 runnable example (contract.py, contract_drills.py, fake_i2c.py) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Interview questions
Warm-up (fresher): Why pass hardware objects into a class instead of creating them inside it?
Because then the class runs anywhere it is given objects with the right methods. A driver that creates
machine.I2C(0) itself can only run where that bus exists, on one kind of board, and a test can replace the bus only
by shadowing the machine module, which behaves differently on CPython, the unix port and a board. A driver that
receives its bus runs on the board’s hardware bus, on a SoftI2C, and in a test on a fake that returns prepared bytes
or raises errors on cue. The same holds for clocks and networks: pass in the time module on the board and a fake
clock in a test.
Three longer questions on fakes start from this lesson:
-
Fresher to mid
An irrigation controller passes all its tests on the unix port in CI, but in the field a Pico now and then runs its pump for hours. The timeout is written as now - started >= 30_000 with values from time.ticks_ms(). Explain the fault and why the tests missed it, and write the test that catches it.
-
Mid to senior
An I2C humidity driver stops the whole app when the sensor's cable is loose, and in the field nobody can tell a loose cable from a missing sensor. Design a fake bus and the tests that make the driver's error handling provably right on a Pico's hardware bus and on SoftI2C.
-
Mid to senior
Your team's fake machine module passed 300 tests, yet the first real board failed within a minute, because the fake did something the real module never does. How do you keep fakes honest from now on?
Flashcards · 6 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
Why is replacing a module with a fake file of the same name fragile?
A time.py wins on MicroPython, where time is extensible, but loses on CPython, whose time is built in; and a fake file that reaches a board replaces the real module there.
-
How does board code let a test hand it fakes?
It receives its pin, its ADC and its clock as arguments and creates none of them: main.py passes Pin(15, Pin.OUT), ADC(26) and the time module, and a test passes fakes.
-
Which methods should a fake have?
Just the methods of the real module that the code under test calls, with nothing added, so a call the board would refuse fails in the test too.
-
Why does a tick-wrap bug never appear when tests run on a 64-bit computer's unix port?
Its ticks wrap at 2⁶², while rp2 and esp32 wrap at 2³⁰, about every 12.4 days.
-
What is a fake clock, and how does it catch wrap bugs?
An object with ticks_ms(), ticks_add() and ticks_diff() that keeps ticks modulo 2³⁰, with advance() to move it on at once. Start it one second before the wrap.
-
What keeps a fake true to the real module?
One contract test that both must pass: the checks of what the code relies on from the module, run against the fake on a computer and against the real module on a board.
Key takeaways
- Passing pins, buses and clocks into code lets a test give it fakes, while shadowing a module behaves differently on CPython, the unix port and a board.
- A fake copies only the real module’s methods that the code needs and adds none, so a call the board lacks fails in the test too.
- A fake clock that wraps at 2³⁰ and starts just before the wrap finds a tick bug at once, which neither the unix port of a 64-bit computer nor days of waiting find.
Terms in this lesson
- contract test
- A test of what code relies on from a module, such as a bus that returns bytes of the length asked for, run against the fake on a computer and against the real module on a board.
- fake clock
- An object with ticks_ms(), ticks_add() and ticks_diff() that a test moves forward by hand. It keeps ticks modulo 2**30 like a 32-bit board, so it can start just before the wrap.
References
- MicroPython libraries (extensible built-in modules) (MicroPython project)
- time – time related functions (ticks_ms, ticks_add, ticks_diff) (MicroPython project)
- The tick arithmetic (extmod/modtime.c, v1.29.0) (MicroPython project)
- The tick period of 2**30 on 32-bit ports (py/mpconfig.h, v1.29.0) (MicroPython project)
- class I2C – a two-wire serial protocol (writeto, readfrom) (MicroPython project)
- errno – system error codes (MicroPython project)
- class WLAN – control built-in WiFi interfaces (status and the STAT_ constants) (MicroPython project)
- The Pico W's STAT_ constants (extmod/modnetwork.c, v1.29.0) (MicroPython project)
- The ESP32 port's STAT_ constants (ports/esp32/modnetwork_globals.h, v1.29.0) (MicroPython project)
- Quick reference for the UNIX and Windows ports (MicroPython project)
Report a problem with this lesson
Kept only in this browser. Your Learn progress