Tutorial 10: Unit Testing#

Unit testing is a software development practice that allows developers to verify the functionality of individual units or components of their codebase. In modsim repositories, unit tests play a vital role in verifying custom scripting libraries tailored to the project. This tutorial introduces a unit_test alias, streamlining the execution of unit tests using the pytest [54] framework.

References#

Environment#

SCons and WAVES can be installed in a Conda environment with the Conda package manager. See the Conda installation and Conda environment management documentation for more details about using Conda.

Note

The SALib and numpy versions may not need to be this strict for most tutorials. However, Tutorial: Sensitivity Study uncovered some undocumented SALib version sensitivity to numpy surrounding the numpy v2 rollout.

  1. Create the tutorials environment if it doesn’t exist

    $ conda create --name waves-tutorial-env --channel conda-forge waves 'scons>=4.6' matplotlib pandas pyyaml xarray seaborn 'numpy>=2' 'salib>=1.5.1' pytest
    
    PS > conda create --name waves-tutorial-env --channel conda-forge waves scons matplotlib pandas pyyaml xarray seaborn numpy salib pytest
    
  2. Activate the environment

    $ conda activate waves-tutorial-env
    
    PS > conda activate waves-tutorial-env
    

Some tutorials require additional third-party software that is not available for the Conda package manager. This software must be installed separately and either made available to SConstruct by modifying your system’s PATH or by modifying the SConstruct search paths provided to the waves.scons_extensions.add_program() method.

Warning

STOP! Before continuing, check that the documentation version matches your installed package version.

  1. You can find the documentation version in the upper-left corner of the webpage.

  2. You can find the installed WAVES version with waves --version.

If they don’t match, you can launch identically matched documentation with the WAVES Command-Line Utility docs subcommand as waves docs.

Directory Structure#

  1. Create and change to a new project root directory to house the tutorial files if you have not already done so. For example

$ mkdir -p ~/waves-tutorials
$ cd ~/waves-tutorials
$ pwd
/home/roppenheimer/waves-tutorials
PS > New-Item $HOME\waves-tutorials -ItemType "Directory"
PS > Set-Location $HOME\waves-tutorials
PS > Get-Location

Path
----
C:\Users\roppenheimer\waves-tutorials

Note

If you skipped any of the previous tutorials, run the following commands to create a copy of the necessary tutorial files.

$ pwd
/home/roppenheimer/waves-tutorials
$ waves fetch --overwrite --tutorial 9 && mv tutorial_09_post_processing_SConstruct SConstruct
WAVES fetch
Destination directory: '/home/roppenheimer/waves-tutorials'
PS > Get-Location

Path
----
C:\Users\roppenheimer\waves-tutorials

PS > waves fetch --overwrite --tutorial 9 && Move-Item tutorial_09_post_processing_SConstruct SConstruct -Force
WAVES fetch
Destination directory: 'C:\Users\roppenheimer\waves-tutorials'

Regression Script#

  1. In the waves-tutorials/modsim_package/python directory, create a new file named regression.py from the contents below

waves-tutorials/modsim_package/python/regression.py

  1"""Perform regression testing on simulation output."""
  2
  3import argparse
  4import pathlib
  5import sys
  6
  7import pandas
  8import yaml
  9
 10
 11def sort_dataframe(
 12    dataframe: pandas.DataFrame,
 13    index_column: str = "time",
 14    sort_columns: list[str] | tuple[str, ...] = ("time", "set_name"),
 15) -> pandas.DataFrame:
 16    """Return a sorted dataframe and set an index.
 17
 18    1. sort columns by column name
 19    2. sort rows by column values ``sort_columns``
 20    3. set an index
 21
 22    :param dataframe: dataframe to sort
 23    :param index_column: name of the column to use an index
 24    :param sort_columns: name of the column(s) to sort by
 25
 26    :returns: sorted and indexed dataframe
 27    """
 28    return dataframe.reindex(sorted(dataframe.columns), axis=1).sort_values(list(sort_columns)).set_index(index_column)
 29
 30
 31def csv_files_match(
 32    current_csv: pandas.DataFrame,
 33    expected_csv: pandas.DataFrame,
 34    index_column: str = "time",
 35    sort_columns: list[str] | tuple[str, ...] = ("time", "set_name"),
 36) -> bool:
 37    """Compare two pandas DataFrame objects and determine if they match.
 38
 39    :param current_csv: Current CSV data of generated plot.
 40    :param expected_csv: Expected CSV data.
 41    :param index_column: name of the column to use an index
 42    :param sort_columns: name of the column(s) to sort by. Defaults to ``["time", "set_name"]``
 43
 44    :returns: True if the CSV files match, False otherwise.
 45    """
 46    current = sort_dataframe(current_csv, index_column=index_column, sort_columns=sort_columns)
 47    expected = sort_dataframe(expected_csv, index_column=index_column, sort_columns=sort_columns)
 48    try:
 49        pandas.testing.assert_frame_equal(current, expected)
 50    except AssertionError as err:
 51        print(
 52            f"The CSV regression test failed. Data in expected CSV file and current CSV file do not match.\n{err}",
 53            file=sys.stderr,
 54        )
 55        equal = False
 56    else:
 57        equal = True
 58    return equal
 59
 60
 61def main(
 62    first_file: pathlib.Path,
 63    second_file: pathlib.Path,
 64    output_file: pathlib.Path,
 65) -> None:
 66    """Compare CSV files and return an error code if they differ.
 67
 68    :param first_file: path-like or file-like object containing the first CSV dataset
 69    :param second_file: path-like or file-like object containing the second CSV dataset
 70    """
 71    regression_results = {}
 72
 73    # CSV regression file comparison
 74    first_data = pandas.read_csv(first_file)
 75    second_data = pandas.read_csv(second_file)
 76    regression_results.update({"CSV comparison": csv_files_match(first_data, second_data)})
 77
 78    with output_file.open(mode="w") as output:
 79        output.write(yaml.safe_dump(regression_results))
 80
 81    if len(regression_results.values()) < 1 or not all(regression_results.values()):
 82        sys.exit("One or more regression tests failed")
 83
 84
 85def get_parser() -> argparse.ArgumentParser:
 86    """Return parser for CLI options.
 87
 88    All options should use the double-hyphen ``--option VALUE`` syntax to avoid clashes with the Abaqus option syntax,
 89    including flag style arguments ``--flag``. Single hyphen ``-f`` flag syntax often clashes with the Abaqus command
 90    line options and should be avoided.
 91
 92    :returns: parser
 93    :rtype:
 94    """
 95    script_name = pathlib.Path(__file__)
 96    default_output_file = f"{script_name.stem}.yaml"
 97
 98    prog = f"python {script_name.name} "
 99    cli_description = "Compare CSV files and return an error code if they differ"
100    parser = argparse.ArgumentParser(description=cli_description, prog=prog)
101    parser.add_argument(
102        "FIRST_FILE",
103        type=pathlib.Path,
104        help="First CSV file for comparison",
105    )
106    parser.add_argument(
107        "SECOND_FILE",
108        type=pathlib.Path,
109        help="Second CSV file for comparison",
110    )
111    parser.add_argument(
112        "--output-file",
113        type=pathlib.Path,
114        default=default_output_file,
115        help="Regression test pass/fail list",
116    )
117    return parser
118
119
120if __name__ == "__main__":
121    parser = get_parser()
122    args = parser.parse_args()
123    main(
124        args.FIRST_FILE,
125        args.SECOND_FILE,
126        args.output_file,
127    )

This script is introduced early for Tutorial 11: Regression Testing because unit testing the post_processing.py functions requires advanced testing techniques. The functions of regression.py can be tested more directly as an introduction to Python unit testing.

Unit test file#

  1. In the waves-tutorials/modsim_package/python/tests directory, Create a new file named test_regression.py from the contents below

waves-tutorials/modsim_package/python/tests/test_regression.py

 1"""Test the regression module."""
 2
 3import pandas
 4
 5from modsim_package.python import regression
 6
 7
 8def test_sort_dataframe() -> None:
 9    """Test :func:`regression.sort_dataframe`."""
10    data = {
11        "time": [0.0, 0.5, 1.0],
12        "Column1": [1, 2, 3],
13        "Column2": [4, 5, 6],
14    }
15    control = pandas.DataFrame.from_dict(data)
16    unsorted_copy = control[["Column2", "Column1", "time"]]
17
18    sorted_control = regression.sort_dataframe(control, sort_columns=["time"])
19    sorted_copy = regression.sort_dataframe(unsorted_copy, sort_columns=["time"])
20
21    pandas.testing.assert_frame_equal(sorted_control, sorted_copy)
22
23
24def test_csv_files_match() -> None:
25    """Test :func:`regression.csv_files_match`."""
26    data = {
27        "time": [0.0, 0.5, 1.0],
28        "Column1": [1, 2, 3],
29        "Column2": [4, 5, 6],
30    }
31    # Control DataFrame
32    control = pandas.DataFrame.from_dict(data)
33
34    # Identical DataFrame
35    identical_copy = control.copy()
36    unsorted_copy = control[["time", "Column2", "Column1"]]
37
38    # Different DataFrame
39    different_copy = control.copy()
40    different_copy.loc[0, "Column1"] = 999
41
42    # Assert that the function returns False when the DataFrames differ
43    assert regression.csv_files_match(control, different_copy, sort_columns=["time"]) is False
44
45    # Assert that the function returns True when the DataFrames are identical
46    assert regression.csv_files_match(control, identical_copy, sort_columns=["time"]) is True
47
48    # Assert that the function returns True when the sorted DataFrames are identical
49    assert regression.csv_files_match(control, unsorted_copy, sort_columns=["time"]) is True

In the test_regression.py file, you’ll find a test implementation of two simple functions within the regression.py module. However, the remaining functions delve into more complex territory which need advanced techniques such as mocking. These aspects are intentionally left as exercises for you, the reader, to explore and master. For a deeper understanding of how mocking operates in Python, refer to Unittest Mock [55]. A more complete suite of unit tests may be found in the ModSim Templates.

SConscript#

  1. In the waves-tutorials directory, create a new file named unit_testing from the contents below

waves-tutorials/unit_testing.scons

 1#! /usr/bin/env python
 2"""Rectangle compression workflow.
 3
 4Requires the following ``SConscript(..., exports={})``
 5
 6* ``env`` - The SCons construction environment with the following required keys
 7"""
 8
 9import pathlib
10
11# Inherit the parent construction environment
12Import("env")
13
14# Set unit test workflow variables
15build_directory = pathlib.Path(Dir(".").abspath)
16workflow_name = build_directory.name
17
18# Collect the target nodes to build a concise alias for all targets
19workflow = []
20
21# Unit test target
22workflow.extend(
23    env.Command(
24        target=[f"{workflow_name}_results.xml"],
25        source=["#/modsim_package/python/tests/test_regression.py"],
26        action="pytest --junitxml=${TARGETS[0]}",
27    )
28)
29
30env.Alias(workflow_name, workflow)
31env.Alias("regression", workflow)

For this SCons task, the primary purpose of the pytest JUnit XML output report is to provide SCons with a build target to track. If the project uses a continuous integration server, the output may be used for automated test reporting [56].

SConstruct#

  1. Update the SConstruct file. A diff against the SConstruct file from Tutorial 09: Post-Processing is included below to help identify the changes made in this tutorial.

waves-tutorials/SConstruct

--- /home/runner/work/waves/waves/build/docs/tutorials_tutorial_09_post_processing_SConstruct
+++ /home/runner/work/waves/waves/build/docs/tutorials_tutorial_10_unit_testing_SConstruct
@@ -106,6 +106,11 @@
     workflow_build_directory = build_directory / pathlib.Path(workflow).stem
     SConscript(workflow, variant_dir=workflow_build_directory, exports={"env": env}, duplicate=False)
 
+# Add unit test target
+test_workflow = pathlib.Path("unit_testing.scons")
+test_build_directory = build_directory / test_workflow.stem
+SConscript(test_workflow, variant_dir=test_build_directory, exports={"env": env}, duplicate=False)
+
 # Comments used in tutorial code snippets: marker-7
 
 # Add default target list to help message

Our test alias is initialized similarly to that of the workflow aliases. In order to clarify that the tests are not part of a modsim workflow, the unit_testing call is made separately from the workflow loop.

Build Targets#

  1. Build the test results

$ pwd
/home/roppenheimer/waves-tutorials
$ scons unit_testing
<output truncated>
PS > Get-Location

Path
----
C:\Users\roppenheimer\waves-tutorials

PS > scons unit_testing
<output truncated>

Output Files#

Explore the contents of the build directory using the tree command against the build directory, as shown below. Note that the output files from the previous tutorials may also exist in the build directory, but the directory is specified by name to reduce clutter in the output shown.

$ pwd
/home/roppenheimer/waves-tutorials
$ tree build/unit_testing/
build/unit_testing/
└── unit_testing_results.xml

0 directories, 1 file
PS > Get-Location

   Path
   ----
   C:\Users\roppenheimer\waves-tutorials

PS > tree build\unit_testing\ /F
C:\USERS\ROPPENHEIMER\WAVES-TUTORIALS\BUILD\UNIT_TESTING
    unit_testing_results.xml

No subfolders exist