Boxes v0.15

In the last few lessons, we have been doing refactoring of our code. We claim it works the same based on an eyeball test: We run it before, we run it later, it looks the same, we claim behavior hasn't changed.

In the lingo of programmers that's called "manual testing" and it has a bad reputation, mostly because it takes a while to do. We have not looked exactly carefully, mostly just check that it looks more or less the same. As we start now the process of fixing bugs which are going to be more and more subtle, that will not be good enough.

We need automated testing. We will now add that using pytest, a nifty tool to write and run automated tests on our code.

We will begin with the justify_row function. By convention, tests go in a folder called tests/ in files called test_something.py and contain functions called test_stuff

Here is the code of that function as shown in the last lesson:

59def justify_row(row, page, separation):
60    """Given a row and a page, adjust position of elements in the row
61    so it fits the page width.
62
63    It modifies the contents of the row in place, so returns nothing.
64    """
65
66    # Remove all right-margin spaces
67    while row[-1].letter == " ":
68        row.pop()
69    slack = (page.x + page.w) - (row[-1].x + row[-1].w)
70    # Get a list of all the ones that are stretchy
71    stretchies = [b for b in row if b.stretchy]
72    if not stretchies:  # Nothing stretches do as before.
73        bump = slack / len(row)
74        # The 1st box gets 0 bumps, the 2nd gets 1 and so on
75        for i, b in enumerate(row):
76            b.x += bump * i
77    else:
78        bump = slack / len(stretchies)
79        # Each stretchy gets wider
80        for b in stretchies:
81            b.w += bump
82        # And we put each thing next to the previous one
83        for j, b in enumerate(row[1:], 1):
84            b.x = row[j - 1].x + row[j - 1].w + separation

So here is our first test! A good test has 4 parts:

  1. setup
  2. precheck
  3. execution
  4. postcheck
 1import boxes
 2
 3
 4def test_justify_simple():
 5    """Test a simple use case."""
 6    # First setup what we will use, our "test case"
 7    row = [boxes.Box(x=i, w=1, h=1, letter="a") for i in range(10)]
 8    page = boxes.Box(w=50, h=50)
 9    separation = .1
10
11    # Check expected characteristics
12    assert len(row) == 10
13    assert row[-1].x + row[-1].w == 10
14
15    # Do the thing
16    boxes.justify_row(row, page, separation)
17
18    # Check the expected behavior
19
20    # Should have the same number of elements
21    assert len(row) == 10
22    # The first element should be flushed-left
23    assert row[0].x == page.x
24    # The last element should be flushed-right
25    assert row[-1].x + row[-1].w == page.x + page.w

To run the test, we use the pytest command. The use of env is an artifact of this project not being in a proper directory structure yet, please disregard.

 1$ env PYTHONPATH=. pytest
 2
 3============================= test session starts ==============================
 4platform linux -- Python 3.6.4, pytest-3.5.0, py-1.5.3, pluggy-0.6.0
 5rootdir: code/lesson4, inifile:
 6collected 1 item
 7
 8tests/test_justify_row.py F                                               [100%]
 9
10=================================== FAILURES ===================================
11_____________________________ test_justify_simple ______________________________
12
13    def test_justify_simple():
14        """Test a simple use case."""
15        # First setup what we will use, our "test case"
16        row = [boxes.Box(x = i, w=1, h=1, letter='a') for i in range(10)]
17        page = boxes.Box(w=50, h=50)
18        separation = .1
19
20        # Check expected characteristics
21        assert len(row) == 10
22        assert row[-1].x + row[-1].w == 10
23
24        # Do the thing
25        boxes.justify_row(row, page, separation)
26
27        # Check the expected behavior
28
29        # Should have the same number of elements
30        assert len(row) == 10
31        # The first element should be flushed-left
32        assert row[0].x == page.x
33        # The last element should be flushed-right
34>       assert row[-1].x + row[-1].w == page.x + page.w
35E       assert (45.0 + 1) == (0 + 50)
36E        +  where 45.0 = Box(45.0, 0, 1, 0, "a").x
37E        +  and   1 = Box(45.0, 0, 1, 0, "a").w
38E        +  and   0 = Box(0, 0, 50, 0, "x").x
39E        +  and   50 = Box(0, 0, 50, 0, "x").w
40
41tests/test_justify_row.py:24: AssertionError
42=========================== 1 failed in 0.29 seconds ===========================

That is a lot, because pytest tries to leave nothing to your imagination. The short version: we had only one test, and it failed. When we expected the last element in the row to be flush to the right side of the page, in position 50, it's only in position 46.

So, hey, we have a bug! Here is how this process works:

  1. Write code
  2. Write a test that describes expected behavior
  3. Run the test:

    • If it fails, fix it
    • If it didn't fail, pat yourself on the back
  4. Repeat from 1. or 2.

So, we have 1. and 2. done, and we tried it, and it failed hard. So, we have to fix it.

Because we are passing a row with no spaces, this is the "no stretchies" part of justify_row:

72    if not stretchies:  # Nothing stretches do as before.
73        bump = slack / len(row)
74        # The 1st box gets 0 bumps, the 2nd gets 1 and so on
75        for i, b in enumerate(row):
76            b.x += bump * i

Why is it failing? I don't know. Yet. Guessing is often useless and it's easier to just look. The general idea is that slack is to be evenly spread by bumping each character a little to the right.

Let's just use a debugger. If you use the --pdb option, pytest will stop when an assertion fails and leave you in a debugger prompt.

 1$ env PYTHONPATH=. pytest --pdb
 2
 3[skipping stuff]
 4
 5>       assert row[-1].x + row[-1].w == page.x + page.w
 6E       assert (45.0 + 1) == (0 + 50)
 7E        +  where 45.0 = Box(45.0, 0, 1, 0, "a").x
 8E        +  and   1 = Box(45.0, 0, 1, 0, "a").w
 9E        +  and   0 = Box(0, 0, 50, 0, "x").x
10E        +  and   50 = Box(0, 0, 50, 0, "x").w
11
12tests/test_justify_row.py:24: AssertionError
13>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> entering PDB >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
14> tests/test_justify_row.py(24)test_justify_simple()
15-> assert row[-1].x + row[-1].w == page.x + page.w
16
17(Pdb)

So, let's examine things.

1(Pdb) row[0].x
20.0
3(Pdb) row[1].x
45.0
5(Pdb) row[2].x
610.0

Hmmmm that looks like the bump we are using is 4. Since bump is slack / len(row) that means slack is 40. That is good.

However, since we have to spread the slack by moving 9 boxes, that means we are only moving the last box by 9 * 4 units, which is 36. If we add the 10 units we were already using, that means the last box will only reach position 46. Which is what we saw in the test!

So, it looks like an off-by-one error. We actually want bump to be larger:

72    if not stretchies:  # Nothing stretches do as before.
73        bump = slack / (len(row) - 1)
74        # The 1st box gets 0 bumps, the 2nd gets 1 and so on
75        for i, b in enumerate(row):
76            b.x += bump * i

And we rerun the test:

1env PYTHONPATH=. pytest
2============================= test session starts ==============================
3platform linux -- Python 3.6.4, pytest-3.5.0, py-1.5.3, pluggy-0.6.0
4rootdir: code/lesson4, inifile:
5collected 1 item
6
7tests/test_justify_row.py .                                               [100%]
8
9=========================== 1 passed in 0.24 seconds ===========================

And it works! We have just written our first test, found a bug, and fixed it.

Can we see the bug in action "In Real Life"? I don't know. Maybe? Creating a test file that exhibits this specific behavior, a line with no spaces that has to be justified into a whole row is not trivial. It probably would happen if enough people fed enough text to the layout engine, in the spirit of the infinite monkeys thing. But it would probably never happen where we could see it.

This specific bug would be too hidden by other behavior to be obvious. It would be "one of those things"... And that is one of the benefits of automated tests. We can just write a test, and from now on, it will have to pass, and this will not break again.

Just as a funny note, I was not expecting this test to fail. This is a real bug I just found while writing this. So, if you ever run into a bug you wrote, don't feel bad, at least it's not in code you are giving people as an example of how to code.

In the next lesson, we will do this a few more times.


results matching ""

    No results matching ""