Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 8

Unit Testing Data Processing with pytest

Recommended resources

Hello! Welcome to the final lesson in our module on Professional Python for Data Engineering.

In our last lesson, we mastered the art of managing project dependencies with virtual environments and requirements.txt. This allows us to create isolated, reproducible "sandboxes" for our data pipelines. Now that we can build a stable environment for our code, we must ensure the code itself is reliable, correct, and robust.

This lesson addresses the learning outcome: Write and run unit tests for data processing logic using pytest.

We will explore how automated tests act as a safety net, allowing you to make changes and add features with confidence. For a data engineer, testing isn't just a good practice—it's essential for preventing data corruption, ensuring accuracy, and maintaining the trust of your stakeholders. We'll use pytest, a powerful and modern Python testing framework, to build this safety net for our data processing logic.

1. The Critical Role of Testing in Data Pipelines

Imagine you deploy a small, seemingly harmless change to a data pipeline. A few hours later, you discover that this change introduced a subtle bug that has corrupted a critical dataset used for company-wide reporting. The dashboards are wrong, trust is broken, and now you have the painful task of finding the root cause and backfilling days of data.

This scenario is all too common. As the resources you will see point out, testing is the primary defense against it.

The essential role of automated tests in data pipelines

To understand the stakes, let's start with the article 'The essential role of automated tests in data pipelines' by Bruno Gonzalez. It opens with a powerful real-world story that perfectly illustrates why testing is non-negotiable for data engineers.

Please read the first three sections: the introduction with the Lyft anecdote, 'What’s the problem with testing?', and 'Solving the problem with testing'. Focus on the high costs of not testing and the different types of tests that exist in a data context.

As the article highlights, there are many types of tests (integration, end-to-end, etc.). Our focus today is on unit tests. A unit test verifies the smallest testable piece of your code—typically a single function or method—in complete isolation. By ensuring each individual component works correctly, we build a strong foundation for the reliability of the entire pipeline.


2. Getting Started with pytest

Python has a built-in testing framework called unittest, but the community has widely adopted pytest for its simplicity and powerful features.

Pytest and Spark Testing Illustration
Testing is a core practice in modern data engineering, and `pytest` is the go-to tool for ensuring your Python and PySpark code is correct and reliable.

Here’s why pytest is a great choice:

  • Simple Syntax: Tests are just regular Python functions, and you use the standard assert keyword, making tests clean and easy to read.
  • Automatic Test Discovery: pytest automatically finds your test files and functions as long as you follow a simple naming convention.
  • Powerful Fixtures: It has a sophisticated system for managing test setup and teardown (e.g., creating sample data or database connections).
  • Rich Plugin Ecosystem: It integrates with hundreds of plugins for things like code coverage (pytest-cov) and mocking (pytest-mock).

Setting Up Your First Test

Let's see how simple it is to get started.

  1. Installation: In your activated virtual environment, install pytest:

    pip install pytest pandas
    
  2. Naming Conventions: pytest looks for files named test_*.py or *_test.py. Inside those files, it runs functions prefixed with test_.

  3. Writing a Test: A test is a function that contains one or more assert statements. An assertion checks if a condition is true. If it is, the test passes. If it's false, the test fails and pytest reports an AssertionError.

To understand the basic workflow, the following video provides a clear and concise demonstration.

Please Learn How To Write Tests in Python… • Pytest Tutorial

The video 'Please Learn How To Write Tests in Python…' from the Tech With Tim channel gives an excellent introduction to the fundamental mechanics of writing and running tests with pytest.

Please watch from the beginning to 05:43. Pay close attention to: How to name the test file (test_main.py). How to write a simple test function (test_get_weather). The use of the assert keyword. How to run the test from the terminal (pytest test_main.py). How to interpret passing and failing test outputs.


3. Writing Tests for Data Processing Logic

Now, let's apply these principles to a common data engineering task: cleaning a Pandas DataFrame.

Imagine we have a function that processes customer data. It's supposed to:

  1. Ensure the customer_id column exists.
  2. Make sure the customer_id is unique and not null.
  3. Check that the age column is an integer.
  4. Verify that all age values are within a reasonable range (e.g., 18 to 120).

Let's write tests for this logic. The following video focuses specifically on testing data pipelines and provides perfect examples for our scenario.

How to test your Python ETL pipelines | Data pipeline | Pytest

The video 'How to test your Python ETL pipelines' from BI Insights Inc. provides a practical, step-by-step guide to writing common data quality tests using pytest and Pandas.

Please watch the following segments: Setting Up the Test (02:32 - 04:55): How to create a test file, import libraries, load sample data into a DataFrame, and write your first test to check for column existence. Testing for Nulls and Uniqueness (06:14 - 07:25): How to write assertions to verify that a primary key column has no nulls and contains only unique values. Testing Data Types (08:30 - 09:36): How to check if a column has the expected data type (e.g., integer or string). Testing Value Ranges (09:36 - 11:19): How to test if values in a numeric column fall within an expected range and if a categorical column's values are from a predefined set.

Let's consolidate what you've just seen into a single example.

Suppose we have this processing function in a file named processing.py:

# processing.py
import pandas as pd

def clean_customer_data(df: pd.DataFrame) -> pd.DataFrame:
    """A simple cleaning function for customer data."""
    # Ensure customer_id is a primary key
    if 'customer_id' not in df.columns:
        raise ValueError("DataFrame must contain a 'customer_id' column.")
    if df['customer_id'].isnull().any():
        raise ValueError("'customer_id' cannot contain nulls.")
    if not df['customer_id'].is_unique:
        raise ValueError("'customer_id' must be unique.")
    
    # Process age
    df['age'] = pd.to_numeric(df['age'], errors='coerce').astype('Int64')
    
    return df

We would create a test file tests/test_processing.py:

# tests/test_processing.py
import pandas as pd
import pytest
from processing import clean_customer_data

def test_column_existence():
    """Tests that the 'customer_id' column exists."""
    data = {'customer_id': [1, 2, 3], 'age': [25, 30, 35]}
    df = pd.DataFrame(data)
    processed_df = clean_customer_data(df)
    assert 'customer_id' in processed_df.columns

def test_customer_id_is_unique_and_not_null():
    """Tests for uniqueness and non-null constraints on customer_id."""
    data = {'customer_id': [1, 2, 3], 'age': [25, 30, 35]}
    df = pd.DataFrame(data)
    # This should run without raising an error
    clean_customer_data(df)

def test_age_is_integer():
    """Tests that the 'age' column is converted to an integer type."""
    data = {'customer_id': [1, 2, 3], 'age': ['25', '30.0', '35']}
    df = pd.DataFrame(data)
    processed_df = clean_customer_data(df)
    assert pd.api.types.is_integer_dtype(processed_df['age'])

# To run these tests, navigate to your project root in the terminal and run:
# pytest

Notice the repetition in creating the pd.DataFrame? Let's fix that.


4. Efficient Testing with Fixtures and Parameterization

Writing the same setup code in every test is inefficient. pytest provides two powerful features to write cleaner, more effective tests: fixtures and parameterization.

Fixtures: Reusable Test Setups

A fixture is a function that pytest runs before your test function. It's perfect for setting up reusable objects like a sample DataFrame, a database connection, or an API client.

How to test your Python ETL pipelines | Data pipeline | Pytest

The BI Insights Inc. video you watched earlier also introduces fixtures. Let's revisit it to see how a fixture simplifies our test setup.

Watch the segment from 07:25 to 08:30. This explains how to wrap the DataFrame creation logic in a fixture function marked with @pytest.fixture and then use it in your test functions.

Using a fixture, we can refactor our previous tests:

# tests/test_processing.py (refactored with a fixture)
import pandas as pd
import pytest
from processing import clean_customer_data

@pytest.fixture
def sample_customer_df():
    """Provides a sample customer DataFrame for tests."""
    data = {'customer_id': [1, 2, 3], 'age': ['25', '30.0', '35']}
    return pd.DataFrame(data)

def test_column_existence(sample_customer_df): # Fixture is passed as an argument
    processed_df = clean_customer_data(sample_customer_df)
    assert 'customer_id' in processed_df.columns

def test_age_is_integer(sample_customer_df):
    processed_df = clean_customer_data(sample_customer_df)
    assert pd.api.types.is_integer_dtype(processed_df['age'])

This is much cleaner! The data setup is defined once and reused across multiple tests.

Parameterization: Testing Many Cases with One Function

Often, you want to test your function against multiple inputs, especially edge cases. Instead of writing a new test function for each case, you can parameterize a single test function.

Please Learn How To Write Tests in Python… • Pytest Tutorial

The Tech With Tim video provides a very clear explanation of parameterized testing using the @pytest.mark.parametrize decorator.

Watch from 17:58 to 21:04. This will show you how to define multiple input/output pairs and have pytest run your test function once for each pair.

Let's apply this to test how our clean_customer_data function handles various bad inputs for the age column.

# tests/test_processing.py (with parameterization)

@pytest.mark.parametrize("age_input, expected_output", [
    ('42', 42),          # Valid string
    (35.0, 35),          # Valid float
    (None, pd.NA),       # Null value
    ('invalid', pd.NA),  # Non-numeric string
])
def test_age_coercion(age_input, expected_output):
    """Tests how the age column is handled with various inputs."""
    data = {'customer_id': [1], 'age': [age_input]}
    df = pd.DataFrame(data)
    processed_df = clean_customer_data(df)
    
    # Use pd.isna for robust NA comparison
    if pd.isna(expected_output):
        assert pd.isna(processed_df['age'].iloc[0])
    else:
        assert processed_df['age'].iloc[0] == expected_output

With this one test function, we've covered four different scenarios!

Test your understanding!

You are writing a transformation function categorize_spend(amount) that returns 'high' if amount > 100, 'medium' if 50 <= amount <= 100, and 'low' if amount < 50.

How would you use @pytest.mark.parametrize to write a single test function that checks the boundaries of this logic (e.g., for amounts like 49.99, 50, 100, and 100.01)?

Show answer

You would create a parameterized test like this:

import pytest
# Assume categorize_spend is in a file named 'transformations.py'
from transformations import categorize_spend

@pytest.mark.parametrize("amount, expected_category", [
    (49.99, 'low'),
    (50, 'medium'),
    (100, 'medium'),
    (100.01, 'high'),
    (0, 'low'),
    (-10, 'low'), # Testing negative values
])
def test_categorize_spend(amount, expected_category):
    assert categorize_spend(amount) == expected_category

5. Isolating Tests with Mocking

A true unit test must be isolated. This means your function's test should not depend on external systems like databases, APIs, or the file system. Why? Because external systems can be slow, unavailable, or have unpredictable states, making your tests unreliable.

Mocking is the technique of replacing these external dependencies with fake, controlled objects during a test.

For example, if your function reads data from a remote CSV file (pd.read_csv("http://example.com/data.csv")), you don't want your test to make a real network request. You would mock the pd.read_csv function to return a predefined DataFrame instead.

Please Learn How To Write Tests in Python… • Pytest Tutorial

Mocking is a crucial but sometimes tricky concept. The Tech With Tim video provides one of the clearest and most comprehensive explanations available.

This is a key concept, so please watch this section carefully from 21:04 to 26:15. It explains: The concept of mocking to isolate your code from external dependencies. How to use the mocker fixture (from the pytest-mock plugin) to patch a function. How to control the return value of the mocked function. How to assert that the mocked function was called correctly.

Let's say we have a function that reads data from a URL.

# processing.py
import pandas as pd

def read_data_from_url(url: str) -> pd.DataFrame:
    """Reads a CSV from a URL and returns a DataFrame."""
    return pd.read_csv(url)

Here's how we'd test it by mocking pd.read_csv:

# tests/test_processing.py
import pandas as pd
from processing import read_data_from_url

def test_read_data_from_url(mocker): # We need the mocker fixture
    """Tests the URL reading function without making a network request."""
    # Create the fake data we want our mock to return
    fake_csv_data = "col1,col2\nval1,val2"
    mock_df = pd.read_csv(pd.io.common.StringIO(fake_csv_data))

    # Patch 'pandas.read_csv' and set its return value
    mocker.patch('pandas.read_csv', return_value=mock_df)

    # Call our function
    test_url = "http://fake-url.com/data.csv"
    result_df = read_data_from_url(test_url)

    # Assert that our function called pd.read_csv with the right URL
    pd.read_csv.assert_called_once_with(test_url)

    # Assert that the function returned the DataFrame we expect
    pd.testing.assert_frame_equal(result_df, mock_df)

In this test, no network call ever happens. We test our function's logic in complete isolation, making the test fast, reliable, and independent of external services.


Conclusion

You have now completed the Professional Python module! You've gone from Python fundamentals to advanced topics like decorators, profiling, dependency management, and now, automated testing. This final lesson is arguably one of the most important for your career as a data engineer.

Key Takeaways:

  • Testing is Essential: Automated tests prevent bugs, ensure data quality, and give you the confidence to refactor and develop pipelines.
  • pytest is Powerful and Simple: Use test_*.py files, test_*() functions, and the assert keyword to write tests. Run them from the terminal with the pytest command.
  • Fixtures for Setup: Use @pytest.fixture to create reusable test data and resources, keeping your tests clean and DRY (Don't Repeat Yourself).
  • Parameterization for Coverage: Use @pytest.mark.parametrize to test multiple scenarios and edge cases with a single test function.
  • Mocking for Isolation: Use mocker to replace external dependencies (files, APIs, databases) to create true, fast, and reliable unit tests.

Preview of the Next Module:
With a solid foundation in professional Python development, we are ready to shift our focus from the code that moves data to the structures that hold data. Our next module, Data Modeling for Analytics, is where we start designing the blueprints for data warehouses and analytical databases. We will begin by exploring the fundamental distinction between two major database design philosophies: OLTP (Online Transaction Processing) and OLAP (Online Analytical Processing). Understanding this difference is the first step toward building efficient and scalable data platforms.