15

How to validate a unit test with random values? I need guarantee that gen_age returns an integer between 15 and 99, but this code is not correct.

import random
import unittest


def gen_age():
    # generate integer between 15 and 99
    return random.randint(15, 99)


class AgeTest(unittest.TestCase):

    def setUp(self):
        self.a = gen_age()

    def test_choice(self):
        element = random.choice(self.a)
        self.assertTrue(element in self.a)

    def test_sample(self):
        for element in random.sample(self.a, 98):
            self.assertTrue(element in self.a)

if __name__ == '__main__':
    unittest.main()
Jonathan Leffler
  • 730,956
  • 141
  • 904
  • 1,278
Regis Santos
  • 3,469
  • 8
  • 43
  • 65
  • the `test_choice` doesn't make sense in conjunction with the `setUp` method, `random.choice` takes a `list` as parameter and self.a is an `int`. – Rafael Barros Sep 29 '14 at 01:39
  • With a test that generates random values, a single test only gives a chance of spotting problems. You generally need to test many times to ensure that you have validated a sufficiently large part of the possible result set. – Jonathan Leffler Sep 29 '14 at 01:40

3 Answers3

33

The best way to test a similar behaviors is to set a seed to the Random object.

The random package provide a Random class. Instances of Random have the same methods than the random package; random(), randint(), sample(), ... In addition, Random accepts a seed. Adding a seed to Random makes it outputs deterministic. For example,

from random import Random
random = Random(666)
assert random.randint(0, 1000) == 467  # will never break

Consequently, you would like to tests your function as

from random import Random
import unittest

random = Random()

def gen_age():
    # generate integer between 15 and 99
    return random.randint(15, 99)


class AgeTest(unittest.TestCase):

    def setUp(self):
        global random
        random = Random(666)

    def test_gen_age(self):
        self.assertEqual(gen_age(), 53)

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

Note that if your test is not in the same file, you will need to patch random using unittest.mock.patch. Something like that should work

from random import Random
from package.file import gen_age
import unittest
from unittest.mock import patch


class AgeTest(unittest.TestCase):

    def setUp(self):
        self.random = Random(666)

    @patch('package.file.random')
    def test_gen_age(self, random):
        random.randint._mock_side_effect = self.random.randint
        self.assertEqual(gen_age(), 53)
dallonsi
  • 1,299
  • 1
  • 8
  • 29
user983716
  • 1,992
  • 1
  • 22
  • 32
  • "Adding a seed to Random makes it outputs deterministic" across different versions of python as well ? Or just different calls within the same version, eg if one used python 3.8 would it definitely return the same values with the same seed as 3.11 – baxx Jun 05 '23 at 16:27
5

I think there is more to build on top of the answer from @user983716 because:

  1. that answer changes the way the source code works by forcing a seed, and the real source is non-deterministic
  2. the test can do much more to document the expected behavior, while that test is obscure and probably confusing to most

The real desire is not to test the functionality of the Random.randint, but rather that the output is correct. This was suggested by @rufanov, though that solution could be improved.

Throughout this answer, we assume that the implementation is in a separate package.file from the tests.

Let's start with the following:

import unittest

from package.file import get_age

class AgeTest(unittest.TestCase):

    def test_gen_age_generates_a_number_between_15_and_99(self):
        age = gen_age()
        self.assertGreaterEqual(age, 15)
        self.assertLessEqual(age, 99)

That's a good test to begin with, because it provides clear output if a failure occurs:

AssertionError: 14 not greater than or equal to 15
AssertionError: 100 not less than or equal to 99

Okay, but we also want to make sure it is a random number, so we can add another test that ensures that we get it from randint as expected:

@unittest.mock.patch('package.file.random')
def test_gen_age_gets_a_random_integer_in_the_range(self, mock_random):
    gen_age()
    mock_random.randint.assert_called_with(15, 99)

We do two important things here:

  • The random object (from the file in which gen_age is defined) is patched so that we can perform testing against it without having to rely upon the real implementation
  • A single assertion is made confirming that the expected 15 and 99 arguments are provided to randint, so that the right range is given

Additional tests could be written that assert against the real return value, confirming that the number given is always the randomized one. This would provide confidence that the method is directly returning the randomized value, as it could be conceivable for the method to do something more, or even return some arbitrary value, even if it internally still makes the randint call.

For example, let's say someone changed gen_age() as follows:

def gen_age():
    age = random.randint(15, 99) # still doing what we're looking for
    return age + 1               # but now we get a result of 16-100

Uh oh, now for only those cases where randint returns 99 will our first test fail, and the second test will still pass. This is a production error waiting to happen...

A simple, but effective way to confirm the result, then, might be as follows:

@unittest.mock.patch('package.file.random')
def test_returns_age_as_generated(mock_random):
    mock_random.return_value = 27
    age = get_age()
    self.assertEqual(age, 27)

There is one final flaw here, though... What if the value returned is changed to this:

def gen_age():
    age = random.randint(15, 99)
    return 27

Now all the tests are passing, but we still aren't getting the randomized result we really want. To fix this we need to randomize the test value too...

It turns out that our source code points to the answer - just use the real implementation as part of that second test. For this, we first need to import the original random:

from package.file import get_age, random

And then we'll modify the last test we wrote, resulting in this:

@unittest.mock.patch('package.file.random')
def test_returns_age_as_generated(mock_random):
    random_age = random.randint(15, 99)
    mock_random.randint.return_value = random_age
    age = get_age()
    self.assertEqual(age, random_age)

Thus, we would end up with the following full suite of tests:

import unittest

from package.file import get_age, random


class AgeTest(unittest.TestCase):

    def test_gen_age_generates_a_number_between_15_and_99(self):
        age = gen_age()
        self.assertGreaterEqual(age, 15)
        self.assertLessEqual(age, 99)

    @unittest.mock.patch('package.file.random')
    def test_gen_age_gets_a_random_integer_in_the_range(self, mock_random):
        gen_age()
        mock_random.randint.assert_called_with(15, 99)

    @unittest.mock.patch('package.file.random')
    def test_returns_age_as_generated(mock_random):
        random_age = random.randint(15, 99)
        mock_random.randint.return_value = random_age
        age = get_age()
        self.assertEqual(age, random_age)
Jake
  • 71
  • 1
  • 4
2

It should be something like this:

def test_GenAge_ReturnIsBetween15And99(self):
    self.assertTrue(self.a >=15 and self.a <= 99);

But you don't realy need test for gen_age function currently. You are trying to test python random generator API, but what is reason? I don't see any.

rufanov
  • 3,266
  • 1
  • 23
  • 41
  • Actually he is trying to test whether the parameters are being passed correctly to the API, and not the APi itself. – almanegra Sep 29 '14 at 01:41
  • 1
    The fact that one random outcome falls within the specified range is no guarantee that they all will. I think OP is looking for some assurance that the max and min are within the range, hard to insure when the outcomes are random. – pjs Sep 29 '14 at 01:41
  • 1
    @almanegra, "to test whether the parameters are being passed correctly"? What are situations in Python can be where some constants will be passed incorrectly(for example, you passed number 15, but function really received 23)? It's not C/C++ where everything can be overrides by preprocessor. – rufanov Sep 29 '14 at 01:48
  • 1
    @pjs, but why he should test that? This library API is already tested by vendor. It's working correctly. It's just single call to thrid-party API and there is no any logic, no any non-constraint values. It just 100% stub function. Stubs should not be tested. – rufanov Sep 29 '14 at 01:54
  • 3
    In this case I agree with your logic, but there are definitely cases where an algorithm relies on randomness and gets hard to test. I'm interested in those cases, which is why I was browsing this question. – pjs Sep 29 '14 at 19:43
  • @pjs, It's not a problem at all. In this case you just must NOT rely on randomness(same as when testing logic based on current datetime, values from database, pages of some dynamic website, etc..). :) In testing environment random class should be replaced by stub/mock, which always provide some constant value. – rufanov Sep 30 '14 at 00:37