Testing files like a pro

Note

Talk from the PyGrunn conference in 2023.

Create files with fake data. In many formats. With no efforts.

Introduction

Thank you for choosing this talk and for being here.

There might be many reasons why you’re here. Perhaps you haven’t done any testing in Python that required files, so you’re curious. Or maybe you have done it many times, but never really liked what you did because it was too verbose or intrusive.

Every time I had to deal with testing files, I had to invent things, reinvent things, recall things from the past, and each time, before diving into a rabbit hole of writing many lines of code or producing yet another collection of files stored somewhere, I checked for available solutions that could simplify things for me, make it easier, less intrusive, and less work. I wanted to make it just fine and enjoyable to work with.

As of today, I have found a solution that works well for me, and that’s what I want to share with you.

Why/motivation

But why, you may ask?

Because test files are often not available when you need them. At least, not at the right time for testing, because your customer or partner doesn’t have them. And even if they do have the right files, there are dozens of reasons for never-ending delays, most of which are related to privacy regulations, such as NDAs to be signed, anonymization, and so on.

And yet, there are deadlines. You have to come up with something, every time. For every project you work on. For every file format you are expected to support.

Or maybe you do have a few test files, and you decide to test your pipeline with the 100 you have (if you’re lucky to have that much) and it all works. Then you go live and discover that your system doesn’t perform well enough to handle thousands of them.

But what are files really? Are they not just pieces of texts and images, sometimes tables, audios and videos, spreadsheets, presentations - all mostly originated from text. We can generate text!

Nowadays, we have concepts such as Synthetic Data and libraries like Faker to support these concepts.

Intermezzo

And if you have never heard of Faker or the term Synthetic Data, I’ll make a quick recap for you.

Synthetic data, or fake data, is computer-generated data that is similar to real-world data. It’s primary purpose is to increase the privacy and integrity of systems.

As everything else in life, it has pros, cons and alternatives.

The pros

  • Data privacy: Because it’s fake - there’s no risk of exposing sensitive user data and no need to comply with data privacy regulations.

  • Scalability: You can generate as much data as you need.

  • Controll: You have full control over the data, so you can test specific rare edge cases.

The cons

  • Realism: Because it’s fake it does not always accurately represent real data or contain the same patterns and anomalies. That could lead to less accurate testing.

  • Generation complexity: Creating realistic data can be complex and time-consuming, depending on the domain and the complexity of the data structures.

  • Maintenance: Keeping the data generation logic up-to-date with evolving application requirements does take time.

The alternatives

  • Production data anonymization: When you take a copy (or subset) of the real production data and anonymize it to remove or obfuscate sensitive information.

  • Manual test data creation: When you manually create test data, usually done for smaller scale or more specific testing.

  • Data augmentation: When you modify existing data to create new data.

All of the alternatives have their pros and cons too, but I’m not going to cover any of that in this presentation.

Faker is a Python package for generating synthetic text data. It’s knows many patterns and locales. It can generate names, texts, addresses, zip codes, ISBN numbers and a lot more.

I started to use Faker around 2016. It was such a relief! You could just do things like this:

from faker import Faker
FAKER = Faker()
FAKER.first_name()
FAKER.last_name()
FAKER.address()
FAKER.zip_code()
FAKER.text()
FAKER.isbn13()
FAKER.email()
FAKER.company_email()
FAKER.company()
FAKER.date_between(start_date="-30y", end_date="+30y")

Before Faker there was Lorem Ipsum (or Lipsum), which was OK (or better than nothing), but didn’t make much sense.

Then Faker (and Faker-like libraries for creating fake data) emerged to save us.

Then test cases became more complex. Primary data sources were often files. We needed to test data/ETL pipelines. Faker still helped a lot, but it was inconvenient to replicate your previous best approach for files and reinvent the wheel for each new project.

That’s why faker-file was created. I wrote it mainly for myself, but you may find it useful too.

How does faker-file help to solve that problem?

In essence, faker-file is just a set of providers for the famous Faker library.

  • You can use it with Faker and factory_boy (for ORM integration).

  • It works with Django.

  • It supports remote storages (AWS S3, Google Cloud Storage, Azure Cloud Storage).

  • You are in control of the generated content. By default, for most basic cases, content it’s generated using Faker’s text method, but you could easily tweak that using the content argument.

You can use it to run a comprehensive integration test of your pipeline in your favorite cloud.

Some of the most commonly-used file formats are supported:

  • BIN

  • CSV

  • DOCX

  • EML

  • EPUB

  • ICO

  • JPEG

  • MP3

  • ODP

  • ODS

  • ODT

  • PDF

  • PNG

  • RTF

  • PPTX

  • SVG

  • TXT

  • WEBP

  • XLSX

  • XML

  • ZIP

Installation

pip install faker-file[common]

Using it is as simple as follows.

Generate a DOCX file with fake content

  • Generate 1 DOCX file with fake content (generated by Faker).

# Import the Faker class from faker package
from faker import Faker

# Import the file provider we want to use
from faker_file.providers.docx_file import DocxFileProvider

FAKER = Faker()  # Initialise Faker instance

FAKER.add_provider(DocxFileProvider)  # Register the DOCX file provider

file = FAKER.docx_file()  # Generate a DOCX file

# Note, that `file` is this case is an instance of either `StringValue`
# or `BytesValue` objects, which inherit from `str` and `bytes`
# respectively, but add meta data. Meta data is stored inside the `data`
# property (`Dict`). One of the common attributes of which (among all
# file providers) is the `filename`, which holds an absolute path to the
# generated file.
print(file.data["filename"])

# Another common attribute (although it's not available for all providers)
# is `content`, which holds the text used to generate the file with.
print(file.data["content"])

Provide content manually

  • Generate 1 DOCX file with developer defined content.

# The text we want have in our generated DOCX file
TEXT = """
"The Queen of Hearts, she made some tarts,
    All on a summer day:
The Knave of Hearts, he stole those tarts,
    And took them quite away."
"""

# Generate a DOCX file with the given text
file = FAKER.docx_file(content=TEXT)
  • Similarly, generate 1 PNG file.

from faker_file.providers.png_file import PngFileProvider

FAKER.add_provider(PngFileProvider)

file = FAKER.png_file()
  • Similarly, generate 1 PDF file. Limit the line width to 80 characters.

from faker_file.providers.pdf_file import PdfFileProvider

FAKER.add_provider(PdfFileProvider)

file = FAKER.pdf_file(wrap_chars_after=80)

Provide templated content

You can generate documents from pre-defined templates.

TEMPLATE = """
{{date}} {{city}}, {{country}}

Hello {{name}},

{{text}}

Address: {{address}}

Best regards,

{{name}}
{{address}}
{{phone_number}}
"""

file = FAKER.pdf_file(content=TEMPLATE, wrap_chars_after=80)

Archive types

ZIP archive containing 5 TXT files

As you might have noticed, some archive types are also supported. The created archive will contain 5 files in TXT format (defaults).

from faker_file.providers.zip_file import ZipFileProvider

FAKER.add_provider(ZipFileProvider)

file = FAKER.zip_file()

ZIP archive containing 3 DOCX files with text generated from a template

from faker_file.providers.helpers.inner import create_inner_docx_file

file = FAKER.zip_file(
    prefix="zzz",
    options={
        "count": 3,
        "create_inner_file_func": create_inner_docx_file,
        "create_inner_file_args": {
            "prefix": "xxx_",
            "content": TEMPLATE,
        },
        "directory": "yyy",
    }
)

Nested ZIP archive

And of course nested archives are supported too. Create a ZIP file which contains 5 ZIP files which contain 5 ZIP files which contain 2 DOCX files.

  • 5 ZIP files in the ZIP archive.

  • Content is generated dynamically.

  • Prefix the filenames in archive with nested_level_1_.

  • Prefix the filename of the archive itself with nested_level_0_.

  • Each of the ZIP files inside the ZIP file in their turn contains 5 other ZIP files, prefixed with nested_level_2_, which in their turn contain 2 DOCX files.

from faker_file.providers.helpers.inner import create_inner_zip_file

file = FAKER.zip_file(
    prefix="nested_level_0_",
    options={
        "create_inner_file_func": create_inner_zip_file,
        "create_inner_file_args": {
            "prefix": "nested_level_1_",
            "options": {
                "create_inner_file_func": create_inner_zip_file,
                "create_inner_file_args": {
                    "prefix": "nested_level_2_",
                    "options": {
                        "count": 2,
                        "create_inner_file_func": create_inner_docx_file,
                        "create_inner_file_args": {
                            "content": TEXT + "\n\n{{date}}",
                        }
                    }
                },
            }
        },
    }
)

It works similarly for EML files (using EmlFileProvider).

from faker_file.providers.eml_file import EmlFileProvider
from faker_file.providers.helpers.inner import create_inner_docx_file

FAKER.add_provider(EmlFileProvider)

file = FAKER.eml_file(
    prefix="zzz",
    content=TEMPLATE,
    options={
        "count": 3,
        "create_inner_file_func": create_inner_docx_file,
        "create_inner_file_args": {
            "prefix": "xxx_",
            "content": TEXT + "\n\n{{date}}",
        },
    }
)

Create a ZIP file with variety of different file types within

  • 50 files in the ZIP archive (limited to DOCX, EPUB and TXT types).

  • Content is generated dynamically.

  • Prefix the filename of the archive itself with zzz_archive_.

  • Inside the ZIP, put all files in directory zzz.

from faker import Faker
from faker_file.providers.helpers.inner import (
    create_inner_docx_file,
    create_inner_epub_file,
    create_inner_txt_file,
    fuzzy_choice_create_inner_file,
)
from faker_file.providers.zip_file import ZipFileProvider
from faker_file.storages.filesystem import FileSystemStorage

FAKER = Faker()
STORAGE = FileSystemStorage()

kwargs = {"storage": STORAGE, "generator": FAKER}
file = ZipFileProvider(FAKER).zip_file(
    prefix="zzz_archive_",
    options={
        "count": 50,
        "create_inner_file_func": fuzzy_choice_create_inner_file,
        "create_inner_file_args": {
            "func_choices": [
                (create_inner_docx_file, kwargs),
                (create_inner_epub_file, kwargs),
                (create_inner_txt_file, kwargs),
            ],
        },
        "directory": "zzz",
    }
)

Another way to create a ZIP file with variety of different file types within

  • 3 files in the ZIP archive (1 DOCX, and 2 XML types).

  • Content is generated dynamically.

  • Filename of the archive itself is alice-looking-through-the-glass.zip.

  • Files inside the archive have fixed name (passed with basename argument).

from faker import Faker
from faker_file.providers.helpers.inner import (
    create_inner_docx_file,
    create_inner_xml_file,
    list_create_inner_file,
)
from faker_file.providers.zip_file import ZipFileProvider
from faker_file.storages.filesystem import FileSystemStorage

FAKER = Faker()
STORAGE = FileSystemStorage()

kwargs = {"storage": STORAGE, "generator": FAKER}
file = ZipFileProvider(FAKER).zip_file(
    basename="alice-looking-through-the-glass",
    options={
        "create_inner_file_func": list_create_inner_file,
        "create_inner_file_args": {
            "func_list": [
                (create_inner_docx_file, {"basename": "doc"}),
                (create_inner_xml_file, {"basename": "doc_metadata"}),
                (create_inner_xml_file, {"basename": "doc_isbn"}),
            ],
        },
    }
)

Using raw=True features in tests

If you pass raw=True argument to any provider or inner function, instead of creating a file, you will get bytes back (or to be totally correct, bytes-like object BytesValue, which is basically bytes enriched with meta-data). You could then use the bytes content of the file to build a test payload as shown in the example test below:

import os
from io import BytesIO

from django.test import TestCase
from django.urls import reverse
from faker import Faker
from faker_file.providers.docx_file import DocxFileProvider
from rest_framework.status import HTTP_201_CREATED
from upload.models import Upload

FAKER = Faker()
FAKER.add_provider(DocxFileProvider)

class UploadTestCase(TestCase):
    """Upload test case."""

    def test_create_docx_upload(self) -> None:
        """Test create an Upload."""
        url = reverse("api:upload-list")

        raw = FAKER.docx_file(raw=True)
        test_file = BytesIO(raw)
        test_file.name = os.path.basename(raw.data["filename"])

        payload = {
            "name": FAKER.word(),
            "description": FAKER.paragraph(),
            "file": test_file,
        }

        response = self.client.post(url, payload, format="json")

        # Test if request is handled properly (HTTP 201)
        self.assertEqual(response.status_code, HTTP_201_CREATED)

        test_upload = Upload.objects.get(id=response.data["id"])

        # Test if the name is properly recorded
        self.assertEqual(str(test_upload.name), payload["name"])

        # Test if file name recorded properly
        self.assertEqual(str(test_upload.file.name), test_file.name)

Create a HTML file predefined template

If you want to generate a file in a format that is not (yet) supported, you can try to use GenericFileProvider. In the following example, an HTML file is generated from a template.

from faker import Faker
from faker_file.providers.generic_file import GenericFileProvider

file = GenericFileProvider(Faker()).generic_file(
    content="<html><body><p>{{text}}</p></body></html>",
    extension="html",
)

Storages

Example usage with Django (using local file system storage)

from django.conf import settings
from faker_file.providers.txt_file import TxtFileProvider
from faker_file.storages.filesystem import FileSystemStorage

STORAGE = FileSystemStorage(
    root_path=settings.MEDIA_ROOT,
    rel_path="tmp",
)

FAKER.add_provider(TxtFileProvider)

file = FAKER.txt_file(content=TEXT, storage=STORAGE)

Example usage with AWS S3 storage

from faker_file.storages.aws_s3 import AWSS3Storage

S3_STORAGE = AWSS3Storage(
    bucket_name="test-bucket",
    root_path="tmp",  # Optional
    rel_path="sub-tmp",  # Optional
    # Credentials are optional too. If your AWS credentials are properly
    # set in the ~/.aws/credentials, you don't need to send them
    # explicitly.
    # credentials={
    #     "key_id": "YOUR KEY ID",
    #     "key_secret": "YOUR KEY SECRET"
    # },
)

file = FAKER.txt_file(storage=S3_STORAGE)

Augment existing files

If you think Faker generated data doesn’t make sense for you and you want your files to look like a collection of 100 files you already have, you could use augmentation features.

You will need additional requirements:

pip install faker-file[ml]

Usage example:

from faker_file.providers.augment_file_from_dir import (
    AugmentFileFromDirProvider,
)

FAKER.add_provider(AugmentFileFromDirProvider)

file = FAKER.augment_file_from_dir(
    source_dir_path="/home/me/Documents/faker_file_source/",
    wrap_chars_after=120,
)

Generated file will resemble text of the original document, but will not be the same.

CLI

Even if you’re not using automated testing, but still want to quickly generate a file with fake content, you could use faker-file:

faker-file generate-completion
source ~/faker_file_completion.sh

Generate an MP3 file:

faker-file mp3_file --prefix=my_file_

Generate 10 DOCX files:

faker-file docx_file --nb_files 10 --prefix=my_file_

Without faker-file

There are alternatives.

You could simply store a collection of test files somewhere. If you do so, make sure you “know” your collection. It should be obvious of how to use it. In other words - document it properly, alongside snippets to make most of it.

Then there comes a natural question - where to store? Should it be centrally hosted or per repository?

An obvious drawback of centrally hosted approach is that modifications become critical. A mistake may cause failure of your CI/CD pipeline. Also, you need to take care of the setup (for both CI/CD and development).

On the other hand, if you do it per project/repository basis, or even using a blue-print repository, you miss these direct contributions to the upstream.

BTW, consider storing your test files in GitLFS.

Besides, adding test files to the repository still feels a little bit strange to me. There’s always a case when you need to have a variation and therefore you need to make another copy, sometimes a very long copy. And oh, refactoring and cleaning up becomes almost unmanageable.

Additionally, you could always go for a mixed approach, when some of the essentially needed files you still do store in the repository (and that can be project specific), while you still make use of the synthetic data for the cases when it’s justified.

Recap/conclusion

  • Most likely, combination of Faker, factory_boy and faker-file will do just fine for your MVP and even way beyond that (you have all in one: synthetic data + dynamic fixtures + generation of files). This approach also saves you from thinking about where to store your test data, and overall, makes your code more manageable and simplifies the development process.

  • If you need to test files in your project, think upfront about the details, such as amount of test files you will need, where to store them, how to store them, etc.

  • If some of your test cases are too specific to replicate with faker-file, consider using hybrid approach.