File: testsuitecontributing.rst

package info (click to toggle)
psychopy 2023.2.4%2Bdfsg-4
  • links: PTS, VCS
  • area: main
  • in suites: sid
  • size: 124,456 kB
  • sloc: python: 126,213; javascript: 11,982; makefile: 152; sh: 120; xml: 9
file content (366 lines) | stat: -rw-r--r-- 18,369 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
Contributing to the |PsychoPy| Test Suite
==========================================

Why do we need a test suite?
------------------------------------------

With any bit of software, no matter how perfect the code seems as you're writing it, there will be bugs. We use a test suite to make sure that we find as many of those bugs as we can before users do, it's always better to catch them in development than to have them mess up someone's experiment once the software is out in the wild. Remember - when a user finds a bug, they react like this:

.. figure:: /images/user-bugs.jpg
  :alt: Starship Trooper flees from a giant bug
  :height: 100px

  "Starship Troopers" (TriStar Pictures; Touchstone Pictures)

...but when the test suite finds a bug, developers react like this:

.. figure:: /images/test-suite-bugs.jpg
  :alt: Baby birds cry for food
  :height: 100px

  "Birds In A Nest" (Robert Lynch)

The more bugs the test suite finds, the better!

How does it work?
------------------------------------------
The test suite uses a Python module called [pytest](https://pypi.org/project/pytest/) to run tests on various parts of the |PsychoPy| code. These tests work by calling functions, initialising objects and generally trying to use as much of the code in the PsychoPy repo as possible - then, if an uncaught error is hit at any point, `pytest` will spit out some informative text on what went wrong. This means that, if the test suite can run without error, then the software can do everything done in the test suite without error.

To mark something as a test, it needs three things:

    1. It must be somewhere in the folder `psychopy/psychopy/tests`
    2. It must contain the word `test` in its name (i.e. the class name and function names)
    4. It must be executable in code, a function or a method

So, for example, if you were to make a test for the `visual.Rect` class, you might call the file `test_rect.py` and put it in `psychopy/psychopy/tests/test_all_visual`, and the file might look like this:

.. code-block:: python

    from psychopy import visual  # used to draw stimuli

    def test_rect():
        # Test that we can create a window and a rectangle without error
        win = visual.Window()
        rect = visual.Rect(win)
        # Check that they draw without error
        rect.draw()
        win.flip()
        # End test
        win.close()

Using `assert`
------------------------------------------

Sometimes there's more to a bit of code than just running without error - we need to check not just that it doesn't crash, but that the output is as expected. The `assert` function allows us to do this. Essentially, `assert` will throw an `AssertionError` if the first input is `False`, with the text of this error determined by the second input. So, for example:

.. code-block:: python

    assert 2 < 1, "2 is not less than 1"


will raise:

.. code-block:: python

    AssertionError: 2 is not less than 1

In essence, an `assert` call is the same as saying:

.. code-block:: python

    if condition == False:
        raise AssertionError(msg)

What this means is that we can raise an error if a value is not what we expect it to be, which will cause the test to fail if the output of a function is wrong, even if the function ran without error.

You could use `assert` within the `test_rect` example like so:

.. code-block:: python

    # Set the rectangle's fill color
    rect.colorSpace = 'rgb'
    rect.fillColor = (1, -1, -1)
    # Check that the rgb value of its fill color is consistent with what we set
    assert rect._fillColor == colors.Color('red'), f"Was expecting rect._fillColor to have an rgb value of '(1, -1, -1)', but instead it was '{rect._fillColor.rgb}'"


Meaning that, if something was wrong with `visual.Rect` such that setting its `fillColor` attribute didn't set the rgb value of its fill color correctly, this test would raise an `AssertionError` and would print both the expected and actual values. This process of comparing actual outputs against expected outputs is known as "end-to-end" (e2e) testing, while simply supplying values to see if they cause an error is called "unit" testing.

Using classes
------------------------------------------

In addition to individual methods, you can also create a `class` for tests. This approach is useful when you want to avoid making loads of objects for each test, as you can simple create an object once and then refer back to it. For example:

.. code-block:: python

    class TestRect:
        """ A class to test the Rect class """
        @classmethod
        def setup_class(self):
            """ Initialise the rectangle and window objects """
            # Create window
            self.win = visual.Window()
            # Create rect
            self.rect = visual.Rect(self.win)

        def test_color(self):
            """ Test that the color or a rectangle sets correctly """
            # Set the rectangle's fill color
            self.rect.colorSpace = 'rgb'
            self.rect.fillColor = (1, -1, -1)
            # Check that the rgb value of its fill color is consistent with what we set
            assert self.rect._fillColor == colors.Color('red'), f"Was expecting rect._fillColor to have an rgb value of '(1, -1, -1)'," \
                                                  f" but instead it was '{self.rect._fillColor.rgb}'"

Of course, you could create a window and a rectangle for each function and it would work just the same, but only creating one means the test suite doesn't have as much to do so it will run faster. Test classes work the same as any other class definition, except that rather than `__init__`, the constructor function should be `setup_class`, and this should be marked as a `@classmethod` as in the example above.


Exercise
__________________________________________

Practicing writing tests? Try extending the above class to test if a created rectangle has 4 vertices.

Running tests in PyCharm
------------------------------------------

One of the really useful features on PyCharm is its ability to run tests with just a click. If you have `pytest` installed, then any valid test will have a green play button next to its name, in the line margins:

.. figure:: /images/run_btn_pycharm.png
  :alt: Arrow pointing to the run button in PyCharm
  :height: 100px

Clicking this button will start all the necessary processes to run this test, just like it would run in our test suite. This button also appears next to test classes, clicking the run button next to the class name will create an instance of that class, then run each of its methods which are valid tests.

Test utils
------------------------------------------

The test suite comes with some handy functions and variables to make testing easier, all of which can be accessed by importing `psychopy.tests.utils`.

Paths
__________________________________________

The test utils module includes the following paths:

- `TESTS_PATH` : A path to the root tests folder
- `TESTS_DATA_PATH` : A path to the data folder within the tests folder - here is where all screenshots, example conditions files, etc. for use by the test suite are stored

Compare screenshot
__________________________________________

This function allows you to compare the appearance of a `visual.Window` to an image file, raising an `AssertionError` if they aren't sufficiently similar. This takes three arguments:

- `fileName` : A path to the image you want to compare against
- `win` : The window you want to check
- `crit` (optional) : A measure of how lenient to be - this defaults to 5, but we advise increasing it to 20 for anything involving fonts as these can vary between machines

If `filename` points to a file which doesn't exist, then this function will instead save the window and assume true. Additionally, if the comparison fails, the window will be saved as the same path as `filename`, but with `_local` appended to the name.

Compare pixel color
__________________________________________

Sometimes, comparing an entire image may be excessive for what you want to check. For example, if you just want to make sure that a fill color has applied, you could just compare the color of one pixel. This means there doesn't need to be a `.png` file in the PsychoPy repository, and the test suite also doesn't have to load a entire image just to compare one color. In these instances, it's better to use `utils.comparePixelColor`. This function takes three arguments:

- `screen` : The window you want to check
- `color` : The color you expect the pixel to be (ideally, this should be a `colors.Color` object)
- `coord` (optional) : The coordinates of the pixel within the image which you're wanting to compare (defaults to `(0, 0)`)

Contained within this function is an `assert` call - so if the two colors are not the same, it will raise an `AssertionError` giving you information on both the target color and the pixel color.

Exemplars and tykes
__________________________________________

While you're welcome to lay out your tests however makes the most sense for that test, a useful format in some cases it to define `list`s of "exemplars" and "tykes" - `dict`s of attributes for use in a `for` loop, to save yourself from manually writing the same code over and over, with "exemplars" being very typical use cases which should definitely work as a bare minimum, and "tykes" being edge cases which should work but are not necessarily likely to occur. Here's an example of this structure:

.. code-block:: python

    from psychopy import visual, colors  # used to draw stimuli


    class TestRect:
        """ A class to test the Rect class """
        @classmethod
        def setup_class(self):
            """ Initialise the rectangle and window objects """
            # Create window
            self.win = visual.Window()
            # Create rect
            self.rect = visual.Rect(self.win)

        def test_color(self):
            """ Test that the color or a rectangle sets correctly """
            # Set the rectangle's fill color
            self.rect.colorSpace = 'rgb'
            self.rect.fillColor = (1, -1, -1)
            # Check that the rgb value of its fill color is consistent with what we set
            assert self.rect._fillColor == colors.Color('red'), f"Was expecting rect._fillColor to have an rgb value of '(1, -1, -1)'," \
                                                  f" but instead it was '{self.rect._fillColor.rgb}'"

        def test_rect_colors(self):
            """Test a range of known exemplar colors as well as colors we know to be troublesome AKA tykes"""
            # Define exemplars
            exemplars = [
                { # Red with a blue outline
                    'fill': 'red',
                    'border': 'blue',
                    'colorSpace': 'rgb',
                    'targetFill': colors.Color((1, -1, -1), 'rgb'),
                    'targetBorder': colors.Color((-1, -1, 1), 'rgb'),
                },
                { # Blue with a red outline
                    'fill': 'blue',
                    'border': 'red',
                    'colorSpace': 'rgb',
                    'targetFill': colors.Color((-1, -1, 1), 'rgb'),
                    'targetBorder': colors.Color((1, -1, -1), 'rgb'),
                },
            ]
            # Define tykes
            tykes = [
                { # Transparent fill with a red border when color space is hsv
                    'fill': None,
                    'border': 'red',
                    'colorSpace': 'rgb',
                    'targetFill': colors.Color(None, 'rgb'),
                    'targetBorder': colors.Color((0, 1, 1), 'hsv'),
                }
            ]
            # Iterate through all exemplars and tykes
            for case in exemplars + tykes:
                # Set colors
                self.rect.colorSpace = case['colorSpace']
                self.rect.fillColor = case['fill']
                self.rect.borderColor = case['border']
                # Check values are the same
                assert self.rect._fillColor == case['targetFill'], f"Was expecting rect._fillColor to be '{case['targetFill']}', but instead it was '{self.rect._fillColor}'"
                assert self.rect._borderColor == case['targetBorder'], f"Was expecting rect._borderColor to be '{case['targetBorder']}', but instead it was '{self.rect._borderColor}'"


Cleanup
------------------------------------------

After opening any windows, initialising objects or opening any part of the app, it's important to do some cleanup afterwards - otherwise these won't close and the test suite will just keep running forever. This just means calling `.Close()` on any `wx.Frame`s, `.close()` on any `visual.Window`s, and using `del` to get rid of any objects.

For functions, you can just do this at the end of the function, before it terminates. For classes, this needs to be done in a method called `teardown_class`; as `pytest` will call this method when the tests have completed. This method also needs to have a decorator marking it as a `classfunction`, like so:

.. code-block:: python

    from psychopy import visual

    class ExampleTest:
        def __init__(self):
            # Start an app
            wx.App()
            # Create a frame
            self.frame = wx.Frame()
            # Create a window
            self.win = visual.Window()
            # Create an object
            self.rect = visual.Rect(win)

        @classmethod
        def teardown_class(self):
            # Close the frame
            self.frame.Close()
            # Close the window
            self.win.close()
            # Delete the object
            del self.rect

Exercise
__________________________________________

Add a `teardown_class` method to your TestRect class.

CodeCov
------------------------------------------

CodeCov is a handy tool which runs the full test suite and keeps track of which lines of code are executed - giving each file in the PsychoPy repo a percentage score for "coverage". If more lines of code in that file are executed when the test suite runs, then it has a higher coverage score. You can view the full coverage report for the repo [here](https://app.codecov.io/gh/psychopy/psychopy/).

Some areas of the code are more important than others, so it's important not to make decisions purely based on what most increases coverage, but coverage can act as a good indicator for what areas the test suite is lacking in. If you want to make a test but aren't sure what to do, finding a file or folder with a poor coverage score is a great place to start!


Solutions
__________________________________________

Testing if a created rectangle has 4 vertices:

.. code-block:: python

    def test_rect(self):
        """ Test that a rect object has 4 vertices """
        assert len(self.rect.vertices) == 4, f"Was expecting 4 vertices in a Rect object, got {len(self.rect.vertices)}"


Adding a `teardown_class` method to your TestRect class:

.. code-block:: python

    class TestRect:
        """ A class to test the Rect class """
        @classmethod
        def setup_class(self):
            """ Initialise the rectangle and window objects """
            # Create window
            self.win = visual.Window()
            # Create rect
            self.rect = visual.Rect(self.win)

        def test_color(self):
            """ Test that the color or a rectangle sets correctly """
            # Set the rectangle's fill color
            self.rect.colorSpace = 'rgb'
            self.rect.fillColor = (1, -1, -1)
            # Check that the rgb value of its fill color is consistent with what we set
            assert self.rect._fillColor == colors.Color('red'), f"Was expecting rect._fillColor to have an rgb value of '(1, -1, -1)'," \
                                                  f" but instead it was '{self.rect._fillColor.rgb}'"

        def test_rect(self):
            """ Test that a rect object has 4 vertices """
            assert len(self.rect.vertices) == 4, f"Was expecting 4 vertices in a Rect object, got {len(self.rect.vertices)}"

        def test_rect_colors(self):
            """Test a range of known exemplar colors as well as colors we know to be troublesome AKA tykes"""
            # Define exemplars
            exemplars = [
                { # Red with a blue outline
                    'fill': 'red',
                    'border': 'blue',
                    'colorSpace': 'rgb',
                    'targetFill': colors.Color((1, -1, -1), 'rgb'),
                    'targetBorder': colors.Color((-1, -1, 1), 'rgb'),
                },
                { # Blue with a red outline
                    'fill': 'blue',
                    'border': 'red',
                    'colorSpace': 'rgb',
                    'targetFill': colors.Color((-1, -1, 1), 'rgb'),
                    'targetBorder': colors.Color((1, -1, -1), 'rgb'),
                },
            ]
            # Define tykes
            tykes = [
                { # Transparent fill with a red border when color space is hsv
                    'fill': None,
                    'border': 'red',
                    'colorSpace': 'rgb',
                    'targetFill': colors.Color(None, 'rgb'),
                    'targetBorder': colors.Color((0, 1, 1), 'hsv'),
                }
            ]
            # Iterate through all exemplars and tykes
            for case in exemplars + tykes:
                # Set colors
                self.rect.colorSpace = case['colorSpace']
                self.rect.fillColor = case['fill']
                self.rect.borderColor = case['border']
                # Check values are the same
                assert self.rect._fillColor == case['targetFill'], f"Was expecting rect._fillColor to be '{case['targetFill']}', but instead it was '{self.rect._fillColor}'"
                assert self.rect._borderColor == case['targetBorder'], f"Was expecting rect._borderColor to be '{case['targetBorder']}', but instead it was '{self.rect._borderColor}'"

        @classmethod
        def teardown_class(self):
            """clean-up any objects, wxframes or windows opened by the test"""
            # Close the window
            self.win.close()
            # Delete the object
            del self.rect