GAMR1520: Markup languages and scripting

GAMR1520

Markup languages and scripting

Unit testing in python

Dr Graeme Stuart


AssertionError

We can add assertions into our code, though it is not something usually needed in simple code.

def something_complex(arg):
    result = str(arg)
    # lots of complex processing here

    # Now we can actively confirm it's a string
    # This is a message to the reader to clarify what just happened
    assert isinstance(result, str), "Woah! This should be a string here."

    # And we continue processing.
    result = "-".join(arg.split())

An assert statement will raise an AssertionError if the condition fails. But we should never rely on these assertion errors, if they are raised, it indicates a bug.

These are ignored by the compiler in optimised mode (python -o).


Unit testing in python

The unittest module allows us to write test code that makes assertions about our application code. Create a tests.py module with classes that inherit from the unittest.TestCase class.

import unittest

class PlayerTestCase(unittest.TestCase):
    pass

class EnemyTestCase(unittest.TestCase):
    pass

End your module with a call to unittest.main().

if __name__ == '__main__':
    unittest.main()

An abstract example

Tests are methods with names that must begin with the word 'test'. When the unittest.main() function is called, all these methods will be executed.

import unittest
import the_thing_to_test

class MyTestCase(unittest.TestCase):
    def test_one_specific_scenario(self):
        self.assertTrue(the_thing_to_test.is_valid)

    def test_a_different_scenario(self):
        self.assertEqual(the_thing_to_test.times_two(3), 6)

if __name__ == '__main__':
    unittest.main()

Within the methods we can call various kinds of assertions.


Assertions

Some common assertions.

self.assertEqual(first, second, msg=None)
self.assertNotEqual(first, second, msg=None)

self.assertTrue(expr, msg=None)
self.assertFalse(expr, msg=None)

self.assertListEqual(first, second, msg=None)
self.assertTupleEqual(first, second, msg=None)
self.assertSetEqual(first, second, msg=None)
self.assertDictEqual(first, second, msg=None)

self.assertAlmostEqual(first, second, places=7, msg=None, delta=None)
self.assertNotAlmostEqual(first, second, places=7, msg=None, delta=None)

More assertions

Some less common assertions.

self.assertGreater(first, second, msg=None)
self.assertGreaterEqual(first, second, msg=None)
self.assertLess(first, second, msg=None)
self.assertLessEqual(first, second, msg=None)

self.assertIs(first, second, msg=None)
self.assertIsNot(first, second, msg=None)

self.assertIsNone(expr, msg=None)
self.assertIsNotNone(expr, msg=None)

self.assertIsInstance(obj, cls, msg=None)
self.assertIsNotInstance(obj, cls, msg=None)

self.fail(msg=None)

The unittest module

import unittest
from my_module import my_function

class TestMyFunction(unittest.TestCase):
    def test_with_zero(self):
        self.assertEqual(my_function(0), 0)

    def test_with_two(self):
        self.assertEqual(my_function(2), 6)

    def test_with_a_billion(self):
        self.assertEqual(my_function(1000000000), 3000000000)

    def test_with_string(self):
        with self.assertRaises(TypeError):
            my_function('should fail with TypeError')

if __name__ == '__main__':
    unittest.main()

A real(ish) example

Download the tests for the core aspects of py2048, a 2048 clone.

2048 game

Look for what is being tested

It will probably be imported at the top of the page and then referenced within each test. For example, this code is testing a module called core (i.e. code in core.py).

This TestStackLeft class is specifically looking at the core.stack_left() function.

"Tests for the core 2048 functions"
import unittest
import core

class TestStackLeft(unittest.TestCase):

    def test_empty(self):
        "An empty row is unaffected by a move"
        result = core.stack_left([None, None, None, None])
        self.assertEqual(result, [None, None, None, None])

The TestStackLeft.test_empty() function is testing the case where an empty row is passed into the core.stack_left function. In this case, the function should have no effect.


Possible core.stack_left implementation

We need to create a core module, that’s just a core.py file.

Inside the file, we need to implement a stack_left() function.

def stack_left(data):
    """Move the non-None items in one row to the left"""
    return [None, None, None, None]

This function passes our test.

Since our tests are incomplete, we can get away with a stupid function that ignores the input and returns the same list every time.


Testing the core.stack_left function under different circumstances.

    def test_one_value(self):
        "A single non-None tile should be moved to the left"
        result = core.stack_left([None, None, 2, None])
        self.assertEqual(result, [2, None,  None, None])

    def test_two_values(self):
        "Two non-None tiles should be moved to the left and retain their order"
        result = core.stack_left([None, 2, None, 4])
        self.assertEqual(result, [2, 4,  None, None])

    def test_three_values(self):
        "Three non-None tiles should be moved to the left and retain their order"
        result = core.stack_left([None, 2, 4, 2])
        self.assertEqual(result, [2, 4, 2, None])

    def test_four_values(self):
        "All non-None tiles should not move"
        result = core.stack_left([4, 2, 4, 2])
        self.assertEqual(result, [4, 2, 4, 2])

Possible core.stack_left implementations

Pass all the tests.

def stack_left(row):
    """Move the non-None items in one row to the left"""
    result = [None, None, None, None]
    stack_index = 0
    for tile in row:
        if tile:
            result[stack_index] = tile
            stack_index += 1
    return result

A more pythonic version which uses the sorted builtin function with a custom key.

def stack_left(row):
    """move the non-None items in one row to the left"""
    return sorted(row, key=lambda tile: tile is None)

Bonus lab exercises from IMAT1914

If you want to work through the process of building the game step-by-step.

There are also videos of me going through the process on panopto, but I can’t work out how to share them with you yet.


The working final game

Download the core.py library of helper functions. It includes stuff like this.

def stack_left(row):
    """move the non-None items in one row to the left"""
    return sorted(row, key=lambda tile: tile is None)

def merge_left(stacked_row):
    """Merge similar non-None items to the left"""
    for i in range(3):
        if stacked_row[i] and stacked_row[i] == stacked_row[i+1]:
            stacked_row[i] *= 2
            stacked_row[i + 1] = None
    return stacked_row

def row_left(row):
    """A full move involves stacking, merging and then stacking again"""
    stacked = stack_left(row)
    merged = merge_left(stacked)
    return stack_left(merged)

A command line interface

The game.py module implements a simple text-based playable game.

SCORE: 0

 .    .    .    .  
 .    .    .    .  
 .    .    .    .  
 .    2    2    .  

move (W=Up, A=Left, S=Down, D=Right, Q=Quit): a

SCORE: 4

 .    .    2    .  
 .    .    .    .  
 .    .    .    .  
 4    .    .    .  

move (W=Up, A=Left, S=Down, D=Right, Q=Quit): 

A tkinter GUI

The gui.py module implements a fully playable tkinter version with keyboard control.

2048 game

Coming Soon

Next year we have some important things to do.

Take a break.

If you enjoyed tkinter, then try to build the 2048 game. The instructions should be clear enough. If you jump ahead to future materials, I’d focus on unit testing for the assignment.

Thanks for listening

Any questions?

We should have plenty of time for questions and answers.

Just ask, you are probably not the only one who wants to know.

Dr Graeme Stuart