Let the computer test your Python GUI application
Let the computer test your Python GUI application
In this article you’ll see how easy it is to write automated tests for Graphical User Interfaces (GUIs) written using the brilliant
guizero Python library.
I’ll start with a horror story which explains why I’m so keen on automated GUI tests.
Next I’ll describe an application that I’m using as an example. The code for the app and the test are available on GitHub; the link is in the resources section at the end of this post.
After that, I’ll show how the tests are built up and describe how they enabled me to find and fix a bug.
A personal horror story
A couple of years ago I presented a Quiz Runner application to an audience of Digital Makers.
The Quiz Runner application used a workstation to manage the Quiz.
Quiz Contestants used micro:bits to ‘buzz’ in when they thought they knew an answer.
The micro:bits communicated via radio using the micro:bit’s built-in radio capability, and everything (workstation and micro:bits) was programmed in Python.
The demo started really well. Then something went wrong with the GUI and I had to abandon the demo. I was annoyed and embarrassed.
My grandfather was a carpenter, as were his father and Grandfather. They were craftsmen in wood. I like to think of myself as a craftsman in software, but I felt I’d just made a door that would not open.
When I had time to explore the problem I found it very hard to reproduce. I needed to hop between the QuizRunner App and the four team micro:bits, clicking and pressing the right things at the right time for dozens of permutations of behaviour.
I gave up.
The first time that you manually test a GUI application, it feels like fun.
The tenth time? Not so much.
The downsides of manual testing
Manual testing has its place, but it can be boring and error-prone. Worse still, there’s no automatic record of what was tested, or what worked.
Because it’s boring, many developers avoid it as far as possible. That can mean that edge cases get tested in QA or production rather than in development. That’s expensive - the later a bug is detected, the greater the cost of fixing it.
So how can you create automated tests for gui-based applications?
How can you use automated tests with GUIs?
There are gui-testing libraries available, but the commercial products are expensive and most of the open-source tools I’ve found are cumbersome.
There is good news, though, if you use Python’s excellent guizero library.
guizerowas written by Laura Sach and Martin O’Hanlon.
They are experienced educators, and they work for the Raspberry Pi Foundation.
guizero is easy to use, it has great documentation and there’s a super Book of Examples!
The book is called Create Graphical User Interfaces with Python, and it’s available from The MagPi website.
I’m a big fan of
guizero. It ticks all the boxes in my Python library checklist, and I use it a lot. The library has lots of automated tests, but the book is aimed at beginners, so it recommends manual testing.
To keep the code simple, the book also makes use of global variables. I’m happy with that in a book for beginners, but experienced software developers try to avoid globals in their code.
I wondered how easy it would be for me to refactor to eliminate the globals, and to remove some code duplication.
Refactoring the original code
Refactoring is a technique that you can use to improve the design of existing code without its external behaviour.
You may have come across Martin Fowler’s book on the subject. It’s a classic, and I refer to it a lot.
I refactored one of my favourite examples from Create Graphical User Interfaces with Python. It’s a game of Noughts and Crosses (or Tic-tac-toe if you’re reading this in North America).
I ended up with code that had no globals and tests that exercised the system thoroughly.
How do the tests work?
Add the magic code that pushes buttons
The most important code is this short fragment:
from guizero import PushButton def push(button: PushButton) -> None: button.tk.invoke()
Set up the test fixtures
You create unit tests by writing Test Cases.
You set up the environment for your tests by creating test fixtures.
Opening a GUI application takes time, so you want to do it once per Test Case.
You do that by writing a class method called
import unittest from tictactoe import TicTacToeApp class TicTacToeTestCase(unittest.TestCase): @classmethod def setUpClass(cls) -> None: cls.app = TicTacToeApp()
You write individual tests by creating Test Case methods whose names start with
The TestCase will run these in random order, so you need to make sure that your tests don’t interfere with each other.
You do that by writing a
setUp method which will reset the game before each test method is run.
def setUp(self) -> None: self.app.reset_board()
This calls the
reset_board method in the application:
def reset_board(self): for x in range(3): for y in range(3): self.square(x, y).text = " " self.square(x, y).enable() self.winner = None self.current_player = 'X' self.message.value = 'It is your turn, X'
Write the tests
Next you write tests to check that the game is working correctly.
Each test simulates a player making a move by clicking on a free cell on the board.
The tests also check whose turn it is before making the move.
The tests use a couple of helper methods to make the tests more readable.
There’s an excellent discussion of test readability in Clean Code. (See the resources at the end of the article.)
Use helper methods to make tests more readable
Here are the helper methods:
def message_value(self): return self.app.message.value def play(self, x, y, player): self.assertEqual(self.message_value(), 'It is your turn, %s' % player) self.push(x, y)
message_value method is just a concise way of finding the text of the last message sent by the game.
play method checks that the last message tells the current player it’s their turn to play, and then clicks on the button that is specified by the x and y coordinates.
Write the first test
The first test just checks that the player changes after a move.
def test_turn_changes_after_player_moves(self): self.play(0, 0, 'X') self.assertEqual(self.message_value(), 'It is your turn, O')
That test passes. That’s good news. It tells you that the refactoring hasn’t broken that behaviour.
Test a game that X wins
Next write a test to check that the game knows when X has won.
def test_knows_if_x_has_won(self): self.play(0, 0, 'X') self.play(0, 1, 'O') self.play(1, 0, 'X') self.play(0, 2, 'O') self.play(2, 0, 'X') self.assertEqual(self.message_value(), 'X wins!')
That passes. You’re on a roll!
Test a win for O
Here’s a game that O wins.
def test_knows_if_o_has_won(self): self.play(0, 0, 'X') self.play(0, 1, 'O') self.play(1, 0, 'X') self.play(1, 1, 'O') self.play(1, 2, 'X') self.play(2, 1, 'O') self.assertEqual(self.message_value(), 'O wins!')
Check for a drawn game
If the last square is filled without either player winning, the game is drawn.
Here’s a test for that:
def test_recognises_draw(self): self.play(0, 0, 'X') self.play(1, 1, 'O') self.play(2, 2, 'X') self.play(0, 1, 'O') self.play(2, 1, 'X') self.play(2, 0, 'O') self.play(0, 2, 'X') self.play(1, 2, 'O') self.play(1, 0, 'X') self.assertEqual("It's a draw", self.message_value())
So far so good. But…
Finding and fixing a bug
When I was writing one of the tests I saw some strange behaviour. When I played the original version of the game I confirmed that it has a bug.
You can carry on making moves after the game has been won!
When you find a bug, you need to do four things.
- Write a test that demonstrates the bug by failing.
- Fix the bug
- Verify that the test now passes
- Check in your code!
Verify the bug
Here’s the test that demonstrates the bug. When you run it on an unfixed application it fails.
def test_game_stops_when_someone_wins(self): self.play(0, 0, 'X') self.play(0, 1, 'O') self.play(1, 0, 'X') self.play(1, 1, 'O') self.play(1, 2, 'X') self.play(2, 1, 'O') # O wins! self.push(0, 2) # should be ignored self.push(2, 0) # should be ignored self.push(2, 2) # should be ignored self.assertEqual(self.message_value(), 'O wins!')
Fix the bug
Here’s the application code that fixes the bug:
def disable_all_squares(self): for i in range(3): for j in range(3): self.square(i, j).disable()
The application needs to invoke that method when a game has been won.
Verify the bug is fixed
If you now run the tests they all pass, so it’s safe to check in your changes.
Success! You now have a working, tested application.
guizero is available on GitHub.
You can install it via pip.
pip3 install guizero
Documentation is available here.
The book ‘Create Graphical User Interfaces with Python’ is available from the MagPi website.
I mentioned two other books:
Questions? Ask in a comment, or tweet me at @RAREblog.
Micro:bit images courtesy of https://microbit.org Radio beacon: https://en.wikipedia.org/wiki/File:Wireless_tower.svg