Search code examples
pythonpydantichypothesis-test

Hypothesis strategies for pydantic nested classes


For our API, I started using hypothesis on top pydantic, in order to enhance test test suite with autogenerated data. Our protocol is hierarchical, so comes easy to me to describe it with nested classes in pydantic:

from pydantic import BaseModel, Field, conint
from hypothesis import given, strategies as st

class Inner(BaseModel):
    start: conint(ge=0, le=9) = Field(description="starting value")
    end: conint(ge=0, le=9) = Field(description="it should be greater than start")


class Outer(BaseModel):
    inner: Inner = Field(description="I level class")

@given(st.builds(Outer))
def test_shema(schema):
    assert schema.inner.start <= schema.inner.end, "Nope!"


test_shema()

All works as expected, but I'm trying to enrich the semantic with some strategies, for example a rule between inner.start and inner.end. In this example one should be greater.

tentative I:

Generate values in separate ranges:

roles = {'inner.start':st.integers(max_value=5), 'inner.end':st.integers(min_value=5)}
@given(st.builds(Outer, **roles))

For this approach I'm not able to specify the field in the inner dataclass, this solution simply doesn't work. Tried nested dicts, dotted notation.

tentative II: Apply a correction to generated test function like this, but only in the test function:

if schema.inner.start >= schema.inner.end:
    schema.inner.start ,schema.inner.end = schema.inner.end ,schema.inner.start

This can be a valid approach, but how to describe it to hypothesis?

Starting from official documentation I cannot find how to define strategies for nested classes or put together the composite in order to apply some logic to the generated schema.


Solution

  • What about this?

    @given(
        st.builds(
            Outer,
            inner=st.builds(
                Inner,
                start=st.integers(min_value=0, max_value=5),
                end=st.integers(min_value=5, max_value=9),
            ),
        )
    )
    

    The documentation shows that you can use a strategy for an arg or kwarg of builds():

    e.g. builds(target, integers(), flag=booleans()) would draw an integer i and a boolean b and call target(i, flag=b).

    So, you could use the builds() strategy to provide the inner kwarg (pydantic expects kwargs in the __init__()), and use a similar approach to customize the start and end with the integers() strategy.

    It seems that you need to provide both min_value and max_value in your scenario because the integers() strategy can generate integers outside the ge and le. I guess it is because integers() cannot know that its values will be used to feed the start and end fields, which seems acceptable to me.