The test runner Mechanism

To create a full test requires bringing together the discovery of all the views and url patterns in the django project, with the scenarios that perform the checks.

Discovery

To perform discovery requires looking at the django url resolver and turning each found pattern into a subclass of django_consistency_enforcer.urls.Pattern to be passed into an instance of the test runner.

There are a few pieces used to make this a reasonably easy process:

from django_consistency_enforcer import urls as enforcer
from django_consistency_enforcer import errors as enforcer_errors

from django.urls import resolvers


def from_raw_pattern(raw_pattern: enforcer.RawPattern, /) -> enforcer.ViewPattern:
    # Not all django views are classes, this helper will extract the class
    # from the view pattern and raise an error if the class is not a subclass
    # of django.views.generic.View
    view_class = enforcer.ensure_raw_pattern_is_generic_view(raw_pattern=raw_pattern)

    # This represents a pattern for a view that is either a plain function
    # Or the function from calling generic.View::as_view
    return enforcer.ViewPattern(
        view_class=view_class,
        raw_pattern=raw_pattern,
        parts=raw_pattern.parts,
        callback=raw_pattern.callback,
        where=raw_pattern.where,
    )

def raw_pattern_excluder(*, raw_pattern: url_helpers.RawPattern) -> bool:
    # Return True if we want to exlude this pattern
    return False

try:
    test_runner = url_helpers.TestRunner.from_raw_patterns(
        raw_patterns=url_helpers.all_django_patterns(resolver=resolvers.get_resolver()),
        raw_pattern_excluder=raw_pattern_excluder,
        pattern_maker=from_raw_pattern,
    )
except enforcer_errors.FoundInvalidPatterns as e:
    raise AssertionError(
        f"Found invalid patterns in ROOT_URLCONF under {configuration}\n\n"
        + "\n\n".join(e.errors.by_most_repeated)
    ) from e

There are a few pieces here:

raw_patterns=url_helpers.all_django_patterns(resolver=resolvers.get_resolver()),

This uses the django_consistency_enforce.urls.all_django_patterns() function with the default django url resolver to recursively find all the known url patterns in the current django environment.

The excluder is used to out right exclude certain patterns from all checks:

raw_pattern_excluder=raw_pattern_excluder,

And finally we provide a factory for creating the actual class that represents each pattern:

pattern_maker=from_raw_pattern,

The type of the test runner will then be generic to the return type of this factory and means that we can return objects with extra functionality on them that custom scenarios may be strongly typed to.

Running scenarios

Once we have our test runner, we may then run all of our scenarios:

from django.apps import apps
from django.conf import settings
from django_consistency_enforcer import errors as enforcer_errors

auth_user_model = apps.get_model(settings.AUTH_USER_MODEL)

# Using the test_runner created in the code example above

try:
    test_runner.run_scenarios(
        auth_user_model=auth_user_model,
        pattern_scenarios=(
            # List here instances of ``PatternScenario`` classes
            ...
        ),
        function_scenarios=(
            # List here instances of ``FunctionScenario`` classes
            ...
        ),
    )
except enforcer_errors.FoundInvalidPatterns as e:
    raise AssertionError(
        f"Found some django views with problems\n\n"
        + "\n\n".join(e.errors.by_most_repeated)
    ) from e

The test runner

class django_consistency_enforcer.urls.TestRunner(patterns: Sequence[T_Pattern])

This object is used to orchestrate the whole test, performing discovery and running the scenarios.

classmethod from_raw_patterns(*, raw_patterns: Iterable[RawPattern], raw_pattern_excluder: RawPatternExcluder | None = None, pattern_maker: PatternMaker[T_Pattern]) Self

Create an instance given all the raw patterns that are relevant, the ability to exclude patterns, and the ability to create fully realised pattern objects from the non excluded raw patterns.

run_scenarios(*, auth_user_model: type, pattern_scenarios: Sequence[PatternScenario[T_Pattern]], function_scenarios: Sequence[FunctionScenario[T_Pattern]]) None

Run pattern and function scenarios against the patterns we know about, providing also the type used by Django to for authenticated of users.

Will collect errors from scenarios and raise them as a group if:

  • Any scenario has exit_early=True and fails.

  • Any errors are found after all the pattern scenarios are run which happens before any function scenarios are run.

  • Any errors are found after all the functional scenarios are run.

Note that the test runner will use pattern.exclude to skip any patterns that do not want to be analysed, and pattern.exclude_function to skip any functions that should not be analysed.

Example

from django_consistency_enforcer import urls as enforcer
from django.contrib.syndication import views as syndication_views


class CustomPattern(ViewPattern[T_ViewClass]):
    def exclude(self, *, auth_user_model: type) -> bool:
        if isinstance(self.callback, syndication_views.Feed):
            # This is based off a weird view in django.contrib that doesn't inherit from generic.View
            # We ignore it to simplify the rest of our checks
            return True

        return super().exclude(auth_user_model=auth_user_model)

    def exclude_function(self, *, auth_user_model: type, function: DispatchFunction) -> bool:
        if function.defined_on and function.defined_on.__module__.startswith("oauth2_provider."):
            # Ignore code that comes from oauth2_provider as it doesn't have explicit request arg
            return True

        return super().exclude_function(auth_user_model=auth_user_model, function=function)


def from_raw_pattern(raw_pattern: enforcer.RawPattern, /) -> CustomPattern:
    view_class = enforcer.ensure_raw_pattern_is_generic_view(raw_pattern=raw_pattern)

    return CustomPattern(
        view_class=view_class,
        raw_pattern=raw_pattern,
        parts=raw_pattern.parts,
        callback=raw_pattern.callback,
        where=raw_pattern.where,
    )


class CustomCheckViewClassRequestAnnotationScenario(
    enforcer.CheckViewClassRequestAnnotationScenario[CustomPattern]
):
    # The CheckViewClassRequestAnnotationScenario is an abc.ABC class
    # that requires some custom implementation which is not included here
    ...


class CustomCheckViewClassRequestAnnotationScenario(
    enforcer.CheckViewClassRequestAnnotationScenario[CustomPattern]
):
    # The CustomCheckPositionalArgsAreCorrectFunctionScenario is an abc.ABC class
    # that requires some custom implementation which is not included here
    ...


try:
    test_runner = url_helpers.TestRunner.from_raw_patterns(
        raw_patterns=url_helpers.all_django_patterns(resolver=resolvers.get_resolver()),
        pattern_maker=from_raw_pattern,
    )
except enforcer_errors.FoundInvalidPatterns as e:
    ...

test_runner.run_scenarios(
    auth_user_model=auth_user_model,
    pattern_scenarios=(
        #
        # Ensure the request annotation on the class is correct
        CustomCheckViewClassRequestAnnotationScenario(),
    ),
    function_scenarios=(
        #
        # Make sure kwargs has an annotation that makes sense
        enforcer.CheckKwargsMustBeAnnotatedFunctionScenario(
            # Exit before other checks in case it's a case of the kwargs
            # Trying to be an Unpack[SomeTypeDict] and forgetting the Unpack
            exit_early=True,
            allows_any=True,
            allows_object=True,
        ),
        #
        # Make sure the positional args are correct
        CustomCheckPositionalArgsAreCorrectFunctionScenario(),
        #
        # Make sure that if the view has a required arg, that this arg is provided by the
        # pattern
        enforcer.CheckRequiredArgsMatchUrlPatternFunctionScenario(),
        #
        # Make sure the view accepts every captured argument
        enforcer.CheckAcceptsArgsFunctionScenario(),
        #
        # Make sure the view has the correct annotations
        enforcer.CheckHasCorrectAnnotationsFunctionScenario(),
    ),
)