3

I want to unit test the following function in Python:

def get_params(env, secret_fetcher):
    try:
        url = env['API_URL']
    except KeyError:
        raise
    try:
        key = secret_fetcher.get_secret('CLIENT-KEY')
        secret = secret_fetcher.get_secret('CLIENT-SECRET')
    except:
        raise
    return url, key, secret

It read one parameter from environment while retrieve the other two from a key vault using an object of KeyVault class secret_fetcher. I call it in my main function like below:

secret_fetcher = SecretFetcher(vault_url)
url, key, secret = get_params(os.environ, secret_fetcher)

I am writing a unit test for this function. For env I am using a dictionary inside the test. However what do I do with second argument of the function whose member function is being called inside the function to test?

class TestApp():
    def test_get_params(self):
        env = {'WrongField': 'http://test.com/123'}
        <mock secret_fetcher>
        self.assertRaises(KeyError, get_params, env, secret)

Do I mock secret_fetcher or secret_fetcher.get_secret? Especially when get_secret returns different value when fed with different argument of its own. Should I mock the class SecretFetcher and implement a function get_secret that returns expected output for argument with these two different values?

ddd
  • 4,665
  • 14
  • 69
  • 125
  • If you are purely testing `get_params`, I am unsure why you are testing `ValueError` as it isn't raised by `get_params`. The only test I would do would to ensure that `secret_fetcher.get_secret` was called with the appropriate arguments with the use of a `unittest.mock.Mock` instance. – metatoaster Jan 21 '21 at 04:19
  • However, if you change the `raise` line to `raise ValueError` (and likely restrict the `except` line to trap a specific exception type), then you should provide a mock `secret_fetcher.get_secret` that would raise the appropriate exception. – metatoaster Jan 21 '21 at 04:21
  • Please refer to [this thread](https://stackoverflow.com/questions/47407741/how-to-mock-just-the-method-inside-the-class) for further details. – metatoaster Jan 21 '21 at 04:23
  • @metatoaster I meant to test assertRaises(KeyError... That was an error – ddd Jan 21 '21 at 04:56

1 Answers1

2

If you are only intending to test the exception as-is, mocking the secret_fetcher argument is basically inconsequential at this stage, as a simple None value will do as it will never be touched, but here's an example to kick things off:

# include the `get_param` function by import or inline here

import unittest
from unittest.mock import Mock

class TestApp(unittest.TestCase):

    def test_get_params_missing_url(self):
        env = {'missing': 'fail'}
        secret_fetcher = Mock()
        with self.assertRaises(KeyError):
            get_params(env, secret_fetcher)

(Do note that I prefer using assertRaises as a context manager to ensure a more natural way of writing the calling of a function; do note that the first exception in the with block will prevent subsequent code from being executed in that block, so it's recommended that only one logical expression be in the assertRaises context manager, or at the very least be the last line; i.e. this can only test one exception at a time)

Running this one test:

$ python -m unittest demo.py 
.
----------------------------------------------------------------------
Ran 1 test in 0.000s

OK

However, given that the theory behind using mocks in the context of unit testing is to enable the testing of the code using the bare minimum external dependencies (i.e. using no other real modules, methods or classes; this keep the testing done against just the relevant unit), using the other features provided by unittest.mock.Mock and friends may simplify this goal.

You may wish to ensure that get_secret was called with the correct argument and the expected result was to be returned, should the correct env was provided. Also testing that the error handling was dealt with as expected. Additional methods that may be appended to the TestApp class above:

    def test_get_params_success(self):
        env = {'API_URL': 'https://api.example.com'}
        def get_secret(arg):
            return arg
        secret_fetcher = Mock()
        secret_fetcher.get_secret.side_effect = get_secret

        url, key, secret = get_params(env, secret_fetcher)
        self.assertEqual(url, 'https://api.example.com')
        self.assertEqual(key, 'CLIENT-KEY')
        self.assertEqual(secret, 'CLIENT-SECRET')

        # Test that the secret_fetcher.get_secret helper was called
        # with both arguments
        secret_fetcher.get_secret.assert_any_call('CLIENT-KEY')
        secret_fetcher.get_secret.assert_any_call('CLIENT-SECRET')
        self.assertEqual(
            secret_fetcher.get_secret.call_args[0], ('CLIENT-SECRET',))

    def test_get_params_failure(self):
        env = {'API_URL': 'https://api.example.com'}
        secret_fetcher = Mock()
        secret_fetcher.get_secret.side_effect = ValueError('wrong value')

        with self.assertRaises(ValueError):
            get_params(env, secret_fetcher)

        # Test secret_fetcher.get_secret helper was only called with
        # the first CLIENT-KEY argument
        # Python 3.8 can check secret_fetcher.get_secret.call_args.args
        self.assertEqual(
            secret_fetcher.get_secret.call_args[0], ('CLIENT-KEY',))

Testing it out:

$ python -m unittest demo.py 
...
----------------------------------------------------------------------
Ran 3 tests in 0.000s

OK

Note that while I have absolute zero information about what your SecretFetcher class does or has, the three test cases together tested the provided get_params function to ensure that it behaves as expected, including testing how it should have used the secret_fetcher.get_secret, that it handles the error as expected, and that with all the provided test cases tested every line of code in the get_params example provided in the question.

Hopefully this served as a comprehensive example on how mocks might be used to satisfy the goals of unit testing.

metatoaster
  • 17,419
  • 5
  • 55
  • 66