Testing Arduino Firmware Without Hardware: Automated Tests with simavr and GitHub Actions

Most Arduino projects are tested the same way: upload the sketch, press some buttons, watch the LEDs. That works until the firmware has timers that run for minutes, safety rules that only matter in rare combinations, or a change that quietly breaks something you tested last month.
Automated tests fix that. This post shows how to test the real compiled firmware with an AVR simulator, without any hardware on the desk.
Why test the compiled firmware
There are two common ways to test embedded code:
- Unit tests on the PC. Compile your logic with a normal compiler and test functions directly. Fast, but you are not testing the code that runs on the chip, and anything touching registers or timers needs to be faked.
- Simulate the microcontroller. Run the exact HEX or ELF file that goes on the board inside a simulator, and test it from outside by driving pins and reading pins.
The second approach catches timer setup mistakes, wrong pin numbers and interrupt problems, which are exactly the bugs that are hardest to find on a bench.
What simavr is
simavr is an open-source simulator for AVR microcontrollers, including the ATmega328P used on the Arduino Uno. It runs the firmware instruction by instruction with simulated timers, ADC, UART and GPIO. A small C program can load the firmware, connect to the simulated pins, and control simulated time.
On Linux or WSL it installs from the package manager along with the AVR toolchain:
sudo apt-get install gcc-avr avr-libc binutils-avr simavr libsimavr-dev libelf-dev
The shape of a test
A firmware test is a script on a timeline:
- Load the compiled firmware into a simulated ATmega328P at 16 MHz.
- Set the starting inputs: switches, sensor voltages on ADC pins, a PIR output.
- Run the simulation in small steps, for example one simulated millisecond at a time.
- At chosen moments, change inputs: "at 5 s the tank probe goes wet", "at 12 s the light level drops".
- After each step, read the outputs and check them against the rules.
Checks come in two kinds:
- Event checks: "the pump relay is on within 3 s of the tank falling below 50 %".
- Invariants: things that must be true at every single step, such as "two crossing traffic greens are never on together" or "the pump never runs while the sump is dry".
Invariants are the most valuable tests for safety logic, because they catch problems at moments you did not think to check.
Making long timers testable
Real controllers wait a long time: five minutes before a pump restart, hours for a street light schedule. Simulated time can run faster than real time, but long waits still slow tests down. Two practical options:
- Build a test variant of the firmware with timers divided by 10 or 100 using a compile-time constant.
- Keep the real timings and simply run the simulation longer. simavr is fast enough that minutes of simulated time take seconds.
Testing the real build at least once is worthwhile, because a speed-up constant can hide an overflow in a long-delay calculation.
Running every test on every push
Once the tests run from the command line, continuous integration is easy. A GitHub Actions workflow on an Ubuntu runner can:
- Install the AVR toolchain and simavr.
- Rebuild every firmware HEX with pinned versions of the Arduino core and libraries.
- Run every simulator test.
- Fail the build if any check fails.
Pinning versions matters. If the toolchain or a library updates silently, the same source can produce different firmware. With fixed versions a rebuild next year gives the same result.
What simulation does not cover
A simulator does not know that a relay contact bounces, that a long sensor cable picks up noise, or that a probe corrodes. Hardware testing is still needed. Automated tests make sure the logic is right and stays right, so bench time goes into the physical problems.
Real examples
Every microcontroller project in my Proteus project collection ships with a simulator test of this kind. The smart street light test drives the light sensor through dusk and dawn and fires the motion sensor. The railway level crossing test runs two trains at once and checks the barriers never lift while a train is on the crossing.