Call Us: (852)37026770  |  Email Us: [email protected]

Python Unit Testing: Best Practices to Follow

Kelly Lee
Digital Content Specialist

What are among the finest practices for Python unit testing? Earlier than we break it down, let’s distinguish unit testing from integration testing:

  • A unit take a look at verifies a unit of code in isolation.
  • An integration take a look at verifies a number of items of code in conjunction.

Crucially, unit assessments mustn’t make community requests, modify database tables, or alter information on disk except there’s an apparent purpose for doing so. Unit assessments use mocked or stubbed dependencies, optionally verifying that they’re referred to as accurately, and make assertions on the outcomes of a single operate or module. Integration assessments, then again, use concrete dependencies and make assertions on the outcomes of a bigger system.

You would possibly disagree on the finer factors in relation to Python, however this distinction works properly for me. I received’t go over unit testing fundamentals, like learn how to run pytest or learn how to use the @patch decorator, assuming you’re acquainted already.

Keep away from Module-Stage Globals

Think about we have now a category outlined in validator.py:

    import os
    import fastjsonschema
    import requests
    schema_url = os.environ['SCHEMA_URL']
    class Validator:
        def __init__(self):
            schema = requests.get(schema_url).json()
            self.validator = fastjsonschema.compile(schema)
        def validate(self, occasion: dict) -> dict:
            return self.validator(occasion)

And we have now a module named my_lambda.py that makes use of the category:

    validator = Validator()
    def handle_event(occasion: dict, context: object) -> dict:
        validator.validate(occasion)
        return {'physique': 'Goodbye!', 'standing': 200}

And we wish to take a look at the module:

    from unittest.mock import Mock, patch
    from my_lambda import handle_event
    @patch('my_lambda.Validator', autospec=True)
    def test_handle_event(mock_validator: Mock):
        outcome = handle_event({'message': 'Hi there!'}, None)
        assert outcome == {'physique': 'Goodbye!', 'standing': 200}

We’re patching the Validator class and never the validator property as a result of the previous will enable us to forestall the community request when Validator() known as, whereas the latter would solely enable us to behave afterwards.

Because it’s written, this take a look at will fail throughout assortment when my_lambda.py is imported, earlier than the patch is even utilized:

    test_my_lambda.py:3: in <module>
        from my_lambda import handle_event
    my_lambda.py:1: in <module>
        from validator import Validator
    validator.py:6: in <module>
        schema_url = os.environ['SCHEMA_URL']
    /usr/native/Cellar/[email protected]/3.9.12/Frameworks/Python.framework/Variations/3.9/lib/python3.9/os.py:679: in __getitem__
        elevate KeyError(key) from None
    E   KeyError: 'SCHEMA_URL'

We might set `SCHEMA_URL` in our .env file, however that’s an additional step that may be required for each developer to run the take a look at. As an alternative, let’s attempt setting it proper earlier than the import:

    import os
    from unittest.mock import Mock, patch
    os.environ['SCHEMA_URL'] = 'foo'
    from my_lambda import handle_event

This may also fail throughout assortment:

    test_my_lambda.py:7: in <module>
        from my_lambda import handle_event
    my_lambda.py:3: in <module>
        validator = Validator()
    validator.py:10: in __init__
        schema = requests.get(schema_url).json()
    ...
    E   requests.exceptions.MissingSchema: Invalid URL 'foo': No scheme provided. Maybe you meant http://foo?

Although we’re patching the Validator class, it’s nonetheless attempting to make a community request as a result of Validator() known as when my_lambda.py is imported, earlier than the patch is even utilized. If I sound like a damaged document, it’s as a result of this may be complicated:

> Module-level globals are assigned when the module is imported, which makes them tough to patch.

The right approach to repair this downside is to get rid of the worldwide from my_lambda.py:

    from validator import Validator
    def handle_event(occasion: dict, context: object):
        validator = Validator()
        validator.validate(occasion)

Now the take a look at passes, nevertheless it’s type of ugly. Let’s take away one other international, this time from validator.py:

    import os
    import fastjsonschema
    import requests
    class Validator:
        def __init__(self):
            schema_url = os.environ['SCHEMA_URL']
            schema = requests.get(schema_url).json()
            self.validator = fastjsonschema.compile(schema)
        def validate(self, occasion: dict) -> dict:
            return self.validator(occasion)

This permits us to simplify the take a look at, restoring it to its unique type:

    from unittest.mock import Mock, patch
    from my_lambda import handle_event
    @patch('my_lambda.Validator', autospec=True)
    def test_handle_event(mock_validator: Mock):
        outcome = handle_event({'message': 'Hi there!'}, None)
        assert outcome == {'physique': 'Goodbye!', 'standing': 200}

Globals Aren’t At all times Dangerous

In case you should use a worldwide for some purpose, set it to None initially:

    from typing import Non-obligatory
    from validator import Validator
    validator: Non-obligatory[Validator] = None
    def handle_event(occasion: dict, context: object):
        international validator
        if not validator:
            validator = Validator()
        validator.validate(occasion)

The bottom line is to forestall any costly computation or community requests when the module is imported to facilitate patching.

Use Dependency Injection

Subsequent, let’s take a look at the category:

from validator import Validator
def test_validator_init():
    outcome = Validator()
    assert outcome.validator isn't None

This take a look at will fail except SCHEMA_URL is outlined within the atmosphere. As beforehand talked about, we might add it to our .env file, or we might set it instantly like we did earlier than with os.environ['SCHEMA_URL'] = 'foo'. It’s higher to make use of the @patch.dict decorator as a result of it can restore the unique worth after the take a look at exits:

    import os
    from unittest.mock import patch
    from validator import Validator
    @patch.dict(os.environ, {'SCHEMA_URL': 'foo'})
    def test_validator_init():
        outcome = Validator()
        assert outcome.validator isn't None

Both method, this take a look at will fail as a result of it’s attempting to make a community request to an invalid URL:

    validator.py:9: in __init__
        schema = requests.get(schema_url).json()
    ...
    E   requests.exceptions.MissingSchema: Invalid URL 'foo': No scheme provided. Maybe you meant http://foo?

Once more, we wish to stop the community request. That is the place dependency injection, also referred to as inversion of management, is useful. Let’s refactor validator.py to simply accept a couple of arguments:

    import os
    from typing import Callable, Non-obligatory
    import fastjsonschema
    import requests
    class Validator:
        def __init__(
            self,
            validator: Non-obligatory[Callable[[dict], dict]] = None,
            schema: Non-obligatory[dict] = None,
            schema_url: Non-obligatory[str] = None,
        ):
            if validator:
                self.validator = validator
                return
            if schema:
                self.validator = fastjsonschema.compile(schema)
                return
            if not schema_url:
                schema_url = os.environ['SCHEMA_URL']
            schema = requests.get(schema_url).json()
            self.validator = fastjsonschema.compile(schema)
        def validate(self, occasion: dict) -> dict:
            return self.validator(occasion)

It’s value a second to know what’s taking place right here when Validator() known as:

  1. If referred to as with a `validator` argument, that argument is assigned to `self.validator`. Some other arguments are ignored.
  2. If referred to as with a `schema` argument, that argument is used to create `self.validator`.
  3. If referred to as with a `schema_url` argument, the schema is requested from the URL, the response is parsed and used to create `self.validator`.
  4. If referred to as with none arguments, the schema is requested from the URL specified within the atmosphere.

This permits us to simply stop the community request when operating the unit take a look at:

    import os
    from unittest.mock import Mock, patch
    from validator import Validator
    def test_validator_init_with_validator():
        mock_validator = Mock()
        outcome = Validator(validator=mock_validator)
        assert outcome.validator == mock_validator

The draw back is we have to cowl the extra complexity within the constructor. Happily, it may be completed with some simple patching:

    @patch('fastjsonschema.compile', autospec=True)
    def test_validator_init_with_schema(mock_compile):
        mock_validator = Mock()
        mock_compile.return_value = mock_validator
        outcome = Validator(schema={'sort': 'string'})
        assert outcome.validator == mock_validator
    @patch('fastjsonschema.compile', autospec=True)
    @patch('requests.get', autospec=True)
    def test_validator_init_with_schema_url(mock_get, mock_compile):
        mock_schema = Mock()
        mock_get.return_value.json.return_value = mock_schema
        mock_validator = Mock()
        mock_compile.return_value = mock_validator
        outcome = Validator(schema_url="foo")
        assert outcome.validator == mock_validator
        mock_get.assert_called_once_with('foo')
    @patch('fastjsonschema.compile', autospec=True)
    @patch('requests.get', autospec=True)
    @patch.dict(os.environ, {'SCHEMA_URL': 'bar'})
    def test_validator_init_with_no_args(mock_get, mock_compile):
        mock_schema = Mock()
        mock_get.return_value.json.return_value = mock_schema
        mock_validator = Mock()
        mock_compile.return_value = mock_validator
        outcome = Validator()
        assert outcome.validator == mock_validator
        mock_get.assert_called_once_with('bar')
    def test_validator_validate():
        mock_validator = Mock()
        mock_validator.return_value = 42
        validator = Validator(mock_validator)
        outcome = validator.validate({})
        assert outcome == 42

This final unit take a look at is dust easy as a result of we don’t actually care how fastjsonschema is applied. It has its personal unit assessments.

Your Recruitment Partner in Hong Kong

Are you
looking for a CHANGE?

Are you
HIRING?