Embedding tests in the same module with the implementation

Viewed 79

I have some utility modules that are independent and too small (~20 lines) to warrant their own tests folder. Is there any convention to embed pytest tests in the same module with the code, to keep it all compact? Tests would not need to use any advanced pytest features (fixtures, scopes, etc.)

E.g.

"""My beautiful sum helper"""

def sum_helper(a, b):
    return a+b


def test_sum_helper():
   assert sum_helper(1, 2) == 3

I understand this convention might need a special launch argument for pytest command.

2 Answers

This a bad practice as your test method be part of your production code. This is something should be avoided.

IMHO, instead as @Guy mentioned you can have a tests folder and rather than creating subfolders or modules inside tests folder you could have a common test module called test_utils and there you can add tests for all of your utility modules.

Yes you correctly mentioned that to get these treated as a test you need a work around, that itself shows its an anti-pattern. Please let me know if you need any more info.

You just need to tell pytest to discover tests within your package folder and look inside all *.py files instead of just test_*.py files. This is python_files option.

You might or might not also also need to give testpaths to pytest. If you are using relative .. imports pytest might struggle on them unless you give explicit --rootdir.

Example command to run pytests

pytest -o "python_files='*.py'" -o "testpaths=src tests" 

There does not seem to be noticeable penalty for looking tests within implementation path - pytest tend to scan files really fast. If you think this is ugly you can just skip import pytest check.

Here is an example implementation with tests attached, with zero run-time penalties. The tests are at the end of the module and only enabled if pytest is installed.

"""CAIP handling.

https://github.com/ChainAgnostic/CAIPs
"""
from dataclasses import dataclass

from eth_utils import is_checksum_address


class BadChainAddressTuple(Exception):
    pass


class InvalidChainId(BadChainAddressTuple):
    pass


class InvalidChecksum(BadChainAddressTuple):
    pass


@dataclass
class ChainAddressTuple:
    """Present one chain-agnostic address"""
    chain_id: int
    address: str

    @staticmethod
    def parse_naive(v: str):
        """
        Example tuple: `1:0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc` - ETH-USDC on Uniswap v2
        """
        assert type(v) == str

        if not v:
            raise BadChainAddressTuple("Empty string passed")

        parts = v.split(":")

        if len(parts) != 2:
            raise BadChainAddressTuple(f"Cannot split chain id in address {v}")

        address = parts[1]
        if not is_checksum_address(address):
            raise InvalidChecksum("Address checksum or format invalid")

        try:
            chain_id = int(parts[0])
        except ValueError:
            raise InvalidChainId(f"Invalid chain_id on {v}")

        if chain_id <= 0:
            raise InvalidChainId("Invalid chain_id")

        return ChainAddressTuple(chain_id, address)


# Run tests with pytest -o "python_files='*.py'"
try:
    # We assume pytest is not installed in the production environment,
    # thus including the test code as part of the module does not incur any extra penalty
    import pytest

    def test_caip_parse_naive():
        tuple = ChainAddressTuple.parse_naive("1:0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc")
        assert tuple.chain_id == 1
        assert tuple.address == "0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc"


    def test_caip_bad_checksum():
        # Notive lower b, as Ethereum encodes the checksum in the hex capitalisation
        with pytest.raises(InvalidChecksum):
            ChainAddressTuple.parse_naive("1:0xb4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc")

except ImportError:
    # pytest not installed, tests not available
    pass


Related