Unit Testing Rules for Our Platform
This guide will provide a comprehensive overview of how to develop and, more importantly, effectively unit test your rules deployed in our platform. Unit testing is a crucial practice for ensuring the reliability, correctness, and maintainability of your rule logic. It's important to note that while unit tests are invaluable for verifying individual components, they do not replace the need for thorough testing of your rules within a complete flow .
1. Understanding Rule Basics (Quick Review)
The creation of a rule consists of two different steps: Writing the actual Python code and configuring the rule within the platform such that it can be used within flows. Rules typically inherit from AbstractRule and implement prepare_context (optional) and apply (mandatory) methods, which contain the main logic. More information about rule creation can be found here: How to write a Rule
2. Structuring Your Unit Tests
To create unit tests for a specific rule, you will typically follow these steps:
- Create a Test Class : For each rule you want to test, create a dedicated test class (e.g., TestMyRule) that inherits from unittest.TestCase.
- Import the Rule : Import the specific rule class you intend to test into your test file.
- Implement the setUp methods : Use the setUpClass and/or setUp method within your test class to initialize common data, mock objects, or set up the environment needed for all tests in that class.
- Write the Test Functions : Create individual test functions (methods starting with test_) for each specific scenario or unit of logic you want to verify.
Note: You can also create a main test class, that sets up common test infrastructure, and have your rule-specific test classes inherit from it.
Example: Basic Test Class Structure
import unittest
from unittest.mock import MagicMock, patch
import pandas as pd
from energyworx import domain, enum
from energyworx.domain import KeyValueType, Namespace, Tag, FlowCancelException, RuleResult, TimeseriesData
from energyworx.enum import RegionTypeEnum
# Import the rule to test
from rules.sample_rule import SampleRule
class TestRuleBasicExampleClass(unittest.TestCase):
@classmethod
def setUpClass(cls):
"""
Set up class-level resources that are shared across all test methods
in this class and its subclasses.
"""
# Initialize a generic Namespace object
cls.namespace = Namespace(
id='dummy-company.com',
name='Dummy Company',
region=RegionTypeEnum.eu,
properties={}
)
# Initialize a generic Datasource object with channels and a basic tag
cls.datasource = domain.Datasource(
id='test-datasource-id',
name='Test Datasource',
description='A generic datasource for unit testing',
timezone='Europe/Amsterdam', # Example timezone
tags=[
Tag(
tag='metadata',
properties=[
KeyValueType(key='type', value='test_type'),
KeyValueType(key='status', value='active')
]
)
],
channels=[
domain.Channel(
id='DUMMY_CHANNEL_2',
internal_id='00000000',
name='Secondary Test Channel',
description='A secondary channel for testing',
classifier='DUMMY2',
classifier_id='22222222',
is_source=True
)
]
)
def setUp(self) -> None:
"""
Set up method for individual test cases.
"""
pass # Or add instance-specific setup if required
def test_basic_example(self):
"""
A simple example test method.
"""
# Instantiate the rule
rule = SampleRule(
datasource=self.datasource,
namespace=self.namespace,
dataframe=pd.DataFrame({'initial_value': [1, 2, 3]}, index=pd.to_datetime(['2024-01-01', '2024-01-02', '2024-01-03'])),
flow_properties={}
)
# Mock any external interactions or internal helper methods
rule.rule_logger = MagicMock()
# Mock timeseries_service.load_timeseries to return a specific DataFrame
rule.load_timeseries = MagicMock(return_value = pd.DataFrame(
{'loaded_value': [10, 20, 30]},
index=pd.to_datetime(['2024-01-01', '2024-01-02', '2024-01-03'])
))
# Set up the 'context' that prepare_context would normally provide if needed
rule.context = {'is_datasource_loaded': True, 'loaded_datasource_timezone': 'Europe/Amsterdam'}
# Call the `apply` method with test inputs
rule_result = rule.apply(input_multiplier=2, process_data=True)
# Assert: Verify the expected outcome
expected_output_df = pd.DataFrame({'initial_value': [2, 4, 6]}, index=pd.to_datetime(['2024-01-01', '2024-01-02', '2024-01-03'])),
self.assertequals(rule_result.result, expected_output_df)
Understanding setUpClass vs. setUp
In unittest , both setUpClass and setUp methods are used for test setup, but they differ significantly:
- setUpClass(cls) :
- Runs once for the entire test class, before any test methods.
- Used for expensive resources shared by all tests (e.g., cls.namespace, cls.datasource).
- Warning : If a test modifies these class-level resources, changes persist and affect subsequent tests in the same class.
- setUp(self) :
- Runs before every single test method.
- Used for resources that need to be fresh and isolated for each individual test, ensuring no test affects another.
In essence, setUpClass is for shared, one-time setup, while setUp is for per-test, isolated setup.
3. Writing Individual Unit Tests
Each test function should focus on a single aspect of your rule's behavior. A common pattern for writing tests is Arrange-Act-Assert :
- Arrange : Set up the test environment, including input data, rule parameters, and any necessary mocks.
- Act : Execute the specific method(s) of your rule that you want to test (e.g., prepare_context, apply, or other rule created functions).
- Assert : Verify that the outcome is as expected using unittest assertion methods (e.g., assertEqual, assertTrue, assertRaises, assertDictEqual, or Pandas-specific assertions).
4. Mocking, Patching and Parameterizing Tests
- Mocking: Use “ MagicMock ” to create mock objects that simulate the behavior of real objects. This is useful for isolating the code you're testing from external dependencies. You can find more information about this package in its official documentation: https://docs.python.org/3/library/unittest.mock.html
- Patching: Use the “ patch ” function to temporarily replace parts of your code with mock objects. This allows you to control the behavior of those parts during your tests. You can find more information about this package in its official documentation: https://docs.python.org/3/library/unittest.mock.html#the-patchers
@parameterizedDecorator: Use this decorator to run a test method multiple times with different input values. This helps you avoid writing repetitive tests. You can find more information about this package in its official documentation: https://pypi.org/project/parameterized/
5. Asserting Differences in Unit Tests
When writing unit tests, you'll use assertion methods to verify that the actual outcome of your code matches what you expect. unittest.TestCase provides many built-in assertion methods for various comparisons. These include: self.assertEqual(a, b, msg=None), self.assertIn(member, container, msg=None), self.assertRaises(exception), and so on.
You can use a combination of unittest 's standard assertions and specialized assertion functions from other libraries like Pandas , depending on the data type you are comparing.
For a comprehensive list of assertion methods and their usage:
- unittest Assertions : https://docs.python.org/3/library/unittest.html#assert-methods
- pandas.testing Assertions : https://pandas.pydata.org/docs/reference/api/pandas.testing.assert_frame_equal.html
6. Readability and Maintainability
Readability and Maintainability
- Clear Naming: Use descriptive names for test classes, test methods, and variables.
- Comments (When Needed): Add comments to explain complex test logic or the purpose of specific test cases.
- Keep Tests Up-to-Date: As you modify your code, make sure to update your unit tests accordingly.
Key Points to Remember
- Unit tests should be small and focused. They should test individual units of code in isolation.
- Unit tests should be fast. They should run quickly so that you can run them frequently.
- Unit tests should be deterministic. They should always produce the same result given the same input.
- Unit tests should be independent. They should not depend on the results of other tests.
Unit tests should be readable. They should be easy to understand and modify.
7. Running Your Tests
To run your unit tests, navigate to your project's root directory in your terminal and execute:
python -m unittest # to execute all tests
python -m unittest tests/unit/rules/test_sample_rule.py
8. Measuring Test Coverage
Test coverage is a metric that indicates the degree to which the source code of a program is executed when a particular test suite is run. It helps you understand how much of your code is actually being tested.
You can install the package and measure coverage with the following commands:
pip install coverage
coverage run -m unittest discover
coverage report -m
For more information on coverage, you can refer to its official documentation: https://coverage.readthedocs.io/en/latest/