User Guide

Defining formclasses

In Plato you define the hierarchical structure of the test data to generate with classes using the formclass decorator. This is similar to Python’s dataclasses. In fact, a class decorated with formclass will be converted to a special dataclass().

Like in dataclasses, you define fields on the formclass using type annotations and optionally default values:

@formclass
class MyFormclass:
    mandatory_field: str
    constant_field: int = 42
    generated_field: str = fake.first_name()

In the following, declaring different types of fields is discussed.

Mandatory fields

For mandatory fields a value must be provided when instantiating the formclass. To make a field mandatory, skip the default value assignment in the field declaration.

@formclass
class MyFormclass:
    mandatory_field: str

MyFormclass(mandatory_field="the value")  # OK

MyFormclass()  # but this raises
Traceback (most recent call last):
    ...
TypeError: __init__() missing 1 required positional argument: 'mandatory_field'

Constant fields

By assigning a constant default value in a field declaration, the field becomes optional on instantiation. It will be initialized with the provided value if not given, but can be overwritten with a different value.

@formclass
class MyFormclass:
    constant_field: str = "default value"

print(MyFormclass().constant_field)
print(MyFormclass(constant_field="different value").constant_field)
default value
different value

Generated fields

Mandatory and constant fields do not really add anything over standard dataclasses. The benefit of Plato is that it allows you to also assign providers to have data generated dynamically.

In the following example we will use the FromFaker provider that exposes the API of the Faker library for generating basic values.

from plato.providers.faker import FromFaker

fake = FromFaker()

@formclass
class MyFormclass:
    generated_field: str = fake.first_name()

When instantiating this formclass, it will have the provider instance assigned to the field:

print(MyFormclass().generated_field)
<plato.providers.faker._FakerMethodProvider object at 0x...>

To get an instance with actual generated values, use the sample() function:

print(sample(MyFormclass()).generated_field)
Alicia

It is possible to overwrite providers with either constant values or different providers:

print(sample(MyFormclass(
    generated_field="fixed value"
)).generated_field)

print(sample(MyFormclass(
    generated_field=fake.postcode()
)).generated_field)
fixed value
16000

The power of Plato is that a formclass instance happens to be also a provider. Thus, a hierachical structure can be declared and the data is generated accordingly.

@formclass
class ComposedClass:
    field0: MyFormclass = MyFormclass()
    field1: MyFormclass = MyFormclass()
    field_with_postcode: MyFormclass = MyFormclass(
        generated_field=fake.postcode()
    )

from dataclasses import asdict
from pprint import pprint

pprint(asdict(sample(ComposedClass())))
{'field0': {'generated_field': 'Joseph'},
 'field1': {'generated_field': 'Sean'},
 'field_with_postcode': {'generated_field': '83827'}}

Class variables

If no type annotation is given or the typing.ClassVar annotation is used, no field is generated and a regular class variable is declared.

@formclass
class MyFormclass:
    class_var0 = "value0"
    class_var1: typing.ClassVar[str] = "value1"

print(MyFormclass.class_var0)
print(MyFormclass.class_var1)
value0
value1
MyFormclass(class_var0="foo")
Traceback (most recent call last):
    ...
TypeError: __init__() got an unexpected keyword argument 'class_var0'

Class variables are not sampled by the sample() function.

Methods

Methods can be added to a formclass.

@formclass
class MyFormclass:
    name: str = "world"

    def greet(self):
        print(f"Hello, {self.name}!")

MyFormclass().greet()
sample(MyFormclass()).greet()
Hello, world!
Hello, world!

Properties

Properties can be added to a formclass.

@formclass
class MyFormclass:
    name: str = "world"

    @property
    def greeting(self) -> str:
        return f"Hello, {self.name}!"

print(MyFormclass().greeting)
Hello, world!

Note that properties are considered fields, in particular when converting the resulting dataclass() to other types.

from dataclasses import asdict, fields

print(
    "Is greeting a field?",
    "greeting" in {f.name for f in fields(MyFormclass)}
)
print(
    "Is greeting part of dict conversion?",
    "greeting" in asdict(MyFormclass())
)
Is greeting a field? False
Is greeting part of dict conversion? False

Derived fields

It can be useful to derive the value of certain fields from other fields. This can be achieved by declaring a method with the derivedfield decorator. Add the dependent fields as arguments to the method.

@formclass
class User:
    first_name: str = fake.first_name()
    last_name: str = fake.last_name()

    @derivedfield
    def email(self, first_name, last_name) -> str:
        return f"{first_name}.{last_name}@example.net"

from dataclasses import asdict
from pprint import pprint

pprint(asdict(sample(User())))
{'email': 'Denise.Wright@example.net',
 'first_name': 'Denise',
 'last_name': 'Wright'}

A derived field can be overwritten with a different value or provider when needed.

pprint(asdict(sample(User(email="my-alias@mailz.org"))))
{'email': 'my-alias@mailz.org', 'first_name': 'Melissa', 'last_name': 'Harris'}

Init-only variables

Sometimes you need values to derive specific field values, but you do not want to store the original value as field in the formclass. Use the InitVar type for this. A field with this type can be provided as usual when constructing the formclass; you even must provide it if it has no default value. While, it is not included in the fields of your formclass, you can use it for your derived fields. Just add an argument with the same name to yout derivedfield method.

from datetime import date, timedelta
from plato import InitVar

@formclass
class User:
    first_name: str = fake.first_name()
    last_name: str = fake.last_name()
    email_domain: InitVar[str] = "example.net"

    @derivedfield
    def email(self, email_domain) -> str:
        return f"{self.first_name}.{self.last_name}@{email_domain}"

pprint(asdict(sample(User(email_domain="mailz.org"))))
{'email': 'Denise.Wright@mailz.org',
 'first_name': 'Denise',
 'last_name': 'Wright'}

Note that email_domain is missing from the output.

Using formclasses

When instantiating a formclass, you obtain a “template” for test data. This allows to change specific values or providers as required by the respective test case. To generate the actual test data, call the sample() on such a “template”.

fake = FromFaker()

@formclass
class User:
    first_name: str = fake.first_name()
    last_name: str = fake.last_name()
    bio: str = ""

    @derivedfield
    def email(self) -> str:
        return f"{self.first_name}.{self.last_name}@example.net"

from dataclasses import asdict
from pprint import pprint

template = User(first_name="Plato", bio=fake.sentence())
pprint(asdict(sample(template)))
{'bio': 'Leg forget run book rise stage house.',
 'email': 'Plato.Wright@example.net',
 'first_name': 'Plato',
 'last_name': 'Wright'}

Sampling the same template multiple times will give different values; making it easy to generate multiple test data instances.

pprint(asdict(sample(template)))
pprint(asdict(sample(template)))
{'bio': 'Station bag whole mission west amount son car.',
 'email': 'Plato.Harris@example.net',
 'first_name': 'Plato',
 'last_name': 'Harris'}
{'bio': 'Though each energy catch pick ever strong bed.',
 'email': 'Plato.Hernandez@example.net',
 'first_name': 'Plato',
 'last_name': 'Hernandez'}

Seeding and reproducibility

Test failures should be reproducible. This also requires that the test data used can be reproduced. Plato ensures this by always using the same default random number generator seed.

If desired, the seed can be set manually. You might want to configure your test framework to do this before every test so that the set of executed tests does not affect the test data.

@formclass
class MyFormclass:
    first_name: str = fake.first_name()

plato.seed(42)
print(sample(MyFormclass()).first_name)
plato.seed(42)
print(sample(MyFormclass()).first_name)
Billy
Billy

To make things even more reproducible, Plato is designed in a way that leaves generated values unaffected if fields are added to or removed from a formclass.

@formclass
class MyFormclass:
    last_name: str = fake.last_name()

plato.seed(42)
print(sample(MyFormclass()).last_name)

@formclass
class MyFormclass:
    first_name: str = fake.first_name()
    last_name: str = fake.last_name()

plato.seed(42)
print(sample(MyFormclass()).last_name)
Bullock
Bullock

However, generated values will change if the field name changes.

@formclass
class MyFormclass:
    first_name: str = fake.last_name()

plato.seed(42)
print(sample(MyFormclass()).first_name)

@formclass
class MyFormclass:
    last_name: str = fake.last_name()

plato.seed(42)
print(sample(MyFormclass()).last_name)
Williams
Bullock

Providers

While a formclass defines the hierarchical structure of test data, a Provider defines how individual values are generated.

Currently, Plato does not really have any providers of its own, but provides the FromFaker class to create providers based on the Faker library. Its delegates all method calls to Faker, but returns a Provider usuable with Plato, instead of a value.

fake = FromFaker()
print(fake.name())
print(sample(fake.name()))
<plato.providers.faker._FakerMethodProvider object at 0x...>
Randy Garcia

The FromFaker can be passed an existing Faker instance. This allows for example to make use of Faker’s localization feature.

from faker import Faker

fake = FromFaker(Faker(["en-US", "de-DE"]))

print("English name:", sample(fake["en-US"].name()))
print("German name:", sample(fake["de-DE"].name()))
English name: Danielle Fletcher
German name: Prof. Helmut Hentschel

Sharing values

Sometimes it is desirable to share the value generated with a provider across multiple fields of a formclass. This can be done with the special Shared provider decorator.

@formclass
class Address:
    street: str = fake.street_address()
    city: str = fake.city()
    postal_code: str = fake.postcode()

@formclass
class Order:
    billing_address: Address = Shared(Address())
    shipping_address: Address = billing_address

from dataclasses import asdict
from pprint import pprint

pprint(asdict(sample(Order())))
{'billing_address': {'city': 'North Reginaburgh',
                     'postal_code': '03314',
                     'street': '310 Edwin Shore Suite 986'},
 'shipping_address': {'city': 'North Reginaburgh',
                      'postal_code': '03314',
                      'street': '310 Edwin Shore Suite 986'}}

This is in particular useful to include fields of a child class within the parent class.

@formclass
class Customer:
    mailing_address: Address = Shared(Address())
    postal_code: str = mailing_address.postal_code

pprint(asdict(sample(Customer())))

Note that both postal_codes in the output are the same:

{'mailing_address': {'city': 'New Shane',
                     'postal_code': '20059',
                     'street': '81646 Rebecca Rapids Suite 486'},
 'postal_code': '20059'}

Implementing custom providers

Custom providers can be implemented with the abstract Provider base class. It only requires to provide an implementation of the Provider.sample() method. That method gets passed a Context which provides a Context.seed that should be used as random number generator seed to ensure reproducability. Alternatively, a seeded Random instance is provided as Context.rng.

from plato.context import Context
from plato.providers import Provider

class RandomFloatProvider(Provider):
    def sample(self, context: Context) -> float:
        return context.rng.random()

@formclass
class MyFormclass:
    number: float = RandomFloatProvider()

print(sample(MyFormclass()).number)
0.9355289927699928

Recipes and typical use cases

Convert to JSON

To convert generated test data to JSON, use asdict() to convert the object into a dictionary first, then use the json module to convert that dictionary to JSON.

from dataclasses import asdict
import json

@formclass
class MyFormclass:
    string_value: str = fake.name()
    number_value: int = 42

data = sample(MyFormclass())
json = json.dumps(asdict(data))
print(json)
{"string_value": "Edwin Ford", "number_value": 42}

Use Plato as builder

TODO

High-level states / variants

TODO

Fill database with SQLAlchemy

TODO