Examples

This section will provide various examples of usage for the gemtest framework.

To see a simple implementation testing the sine function using metamorphic tests, look at the example provided in the Quick-Start Guide.

An example implementation for the sine function using the general approach can be found in the Advanced Usage section.

Registering multiple SUTs

The knapsack example demonstrates how to register multiple SUTs in the same test suite.

Defining multiple SUTs especially makes sense if:

  1. There are multiple algorithms available to solve the same problem (such as greedy and brute force for Knapsack).

  2. The same transformations and relations can be used for the different SUTs.

Defining the Metamorphic Relation

To test the knapsack problem, we first define the following two metamorphic relations:

add = gmt.create_metamorphic_relation(name="add", data=knapsacks)
combine = gmt.create_metamorphic_relation(
    name="combine",
    data=knapsacks,
    number_of_sources=2,
    testing_strategy=gmt.TestingStrategy.SAMPLE,
    number_of_test_cases=10
)

Defining Transformations and Relations

We then define transformations and their corresponding relations for the add and combine relations.

@gmt.transformation(add)
@gmt.randomized('items_to_add', gmt.RandInt(1, 10))
def add_items(knapsack: Knapsack, items_to_add: int):
    knapsack.add_items(items_to_add)
    return knapsack

@gmt.relation(add)
def check_add_items(source_output: int, followup_output: int):
    return source_output <= followup_output

@gmt.general_transformation(combine)
def combine_knapsacks(mtc: gmt.MetamorphicTestCase):
    knapsack1, knapsack2 = mtc.source_inputs
    combined_max_weight = knapsack1.max_weight + knapsack2.max_weight
    combined_items = knapsack1.items + knapsack2.items
    return Knapsack(max_weight=combined_max_weight, items=combined_items)

@gmt.general_relation(combine)
def check_combine_knapsacks(mtc: gmt.MetamorphicTestCase):
    return mtc.source_outputs[0] + mtc.source_outputs[1] <= mtc.followup_output

Registering the SUTs

We define the first SUT using the greedy algorithm to solve the knapsack problem.

@gmt.system_under_test(add, combine)
def test_knapsack_greedy(knapsack: Knapsack):
    max_value, _ = greedy(knapsack)
    print(f"max value {max_value}: knapsack {knapsack}")
    return max_value

Additionally, we want to test a brute-force implementation and register a second SUT as follows:

@gmt.system_under_test(add, combine)
def test_knapsack_brute_force(knapsack: Knapsack):
    max_value, selected_items = brute_force(knapsack)
    print(f"max value {max_value}: knapsack {knapsack}")
    return max_value

Warning

The brute force implementation has exponential time complexity, and it is recommended to keep the brute force implementation commented out to reduce runtimes!

Reusing Parameters

As explained in the Decorator Section of the Advanced Usage Guide, Parameters generated by @randomized and @fixed are stored in the parameters dictionary of the individual Test Cases.

In some cases, it is necessary to reuse the parameters from the transformation functions. It is also advised to do so in many cases since it creates a stronger test oracle. The following simple Sine test example, similar to the general approach example, demonstrates how to use the various parameters.

Defining Parameters in the Metamorphic Relation

We can set the parameters in a dictionary and pass them as an argument when creating the metamorphic relation.

parameters = {"random_t": [1, 2, 3]}

mr_1 = gmt.create_metamorphic_relation(name='mr_1',
                                    data=range(100),
                                    testing_strategy=gmt.TestingStrategy.EXHAUSTIVE,
                                    number_of_sources=1,
                                    parameters=parameters,
                                    number_of_test_cases=10)

Contrary to using the @randomized and @fixed decorators, setting the parameters using the dictionary will result in multiple test cases being created.

Warning

The default TestingStrategy is EXHAUSTIVE, meaning that all combinations of inputs are tested. We will test for all possible parameter permutations! See the Testing Strategy Section in the Advanced Usage Guide!

Generating Parameters using Decorators

Using @randomized and @fixed decorators will store the parameters in the same dictionary. Make sure that you use a different key name for each parameter.

@gmt.general_transformation(mr_1)
@gmt.randomized('n', gmt.RandInt(1, 10))
@gmt.fixed('c', 0)
def dummy_general_transformation(mtc: gmt.MetamorphicTestCase, n: int, c: int):
    t = mtc.parameters['random_t']
    followup = mtc.source_input + 2 * (n * t) * math.pi + c
    return followup

The parameters n and c have to be passed as arguments in the dummy_general_transformation. To use parameters set during the creation of the metamorphic relation, pass the MetamorphicTestCase object and access the parameter using mtc.parameters['paramter_name'].

Reusing the Parameters in a Relation

With all the parameters being present in the MetamorphicTestCase object, we need to pass it as an argument in the dummy_general_relation. For example, in the transformation function we access parameters using mtc.parameters.

@gmt.general_relation(mr_1)
def dummy_general_relation(mtc: gmt.MetamorphicTestCase):
    n = mtc.parameters['n']
    c = mtc.parameters['c']
    t = mtc.parameters['random_t']
    result = mtc.source_output + n*t + c == pytest.approx(mtc.followup_output + n*t + c)
    return result

MaxFlow Solver Example reusing Parameters

This section provides another example of reusing parameters for a stronger test oracle. Here, we test various MaxFlow solvers, such as the Ford-Fulkerson Algorithm.

Setting the Parameters

We first need to define a dictionary containing our specified parameters:

scalar_dict = {"scalar": [2.0, 7.0, 10.0], "fraction": [0.5, 0.75]}

Note

Usually, for metamorphic testing, we would like to randomize these parameters as well. Here, they are fixed.

Defining the Metamorphic Relation

We then define the metamorphic relations, setting the source data and the parameters as well.

scale_cap = gmt.create_metamorphic_relation(name="scale_cap", data=graphs, parameters=scalar_dict)
scale_cap_ratio = gmt.create_metamorphic_relation(name="scale_cap_ratio", data=graphs, parameters=scalar_dict)

General Transformation and Relation

The parameters can be used in the general transformation when passing the MetamorphicTestCase object as input to the transformation function. You can access the parameters using mtc.parameters['key'], where the given key matches a key defined in your parameter dictionary.

@gmt.general_transformation(scale_cap)
def scale_capacities_params(mtc: gmt.MetamorphicTestCase):
    """
    Scales the Capacities in the Capacity Matrix by the scalars
    specified in the parameters dictionary.
    """
    network_graph = mtc.source_input
    scalar = mtc.parameters["scalar"]
    return network_graph.scale_capacities_params(scalar)

@gmt.general_transformation(scale_cap_ratio)
def scale_capacities_params(mtc: gmt.MetamorphicTestCase):
    """
    Scales the Capacities in the Capacity Matrix by the scalars
    specified in the parameters dictionary.
    """
    network_graph = mtc.source_input
    scalar = mtc.parameters["fraction"]
    return network_graph.scale_capacities_params(scalar)

The corresponding general relations then expect the MetamorphicTestCase object as input as well. You can use the parameters equivalently to how you used them in the transformation.

@gmt.general_relation(scale_cap)
def flow_scaled(mtc: gmt.MetamorphicTestCase):
    """
    Verifies that the maximum flow of the follow_up output is exactly scaled by the scalar
    used for increasing the capacities.
    """
    return gmt.approximately((mtc.source_output * mtc.parameters["scalar"]), mtc.followup_output)

@gmt.general_relation(scale_cap_ratio)
def flow_fraction(mtc: gmt.MetamorphicTestCase):
    """
    Verifies that the maximum flow of the follow_up output is exactly scaled by the scalar
    used for increasing the capacities.
    """
    return gmt.approximately((mtc.source_output * mtc.parameters["fraction"]), mtc.followup_output)

Warning

The default TestingStrategy is EXHAUSTIVE, meaning that all combinations of inputs are tested. See the Testing Strategy Section in the Advanced Usage Guide!

Sorting Lists Example

This section illustrates the two options for reusing parameters, either by generating parameters using @gmt.randomized or @gmt.fixed or by using predefined dictionaries. To see the full test suite, see the sorting example in the example folder. The stable test suites use generated parameters, while the unstable test suite utilizes predefined parameters.

Generated Parameters

This section illustrates how to apply metamorphic testing to sorting algorithms. Again, we will make use of reusable parameters.

The generated lists consist of value and identifier tuples and will be sorted by their values. The identifiers serve the purpose of keeping track of the exact element that is removed.

List Generation and MR creation

For this example, we generate lists of 20 elements. The values will be random integers from 1 to 100 with identifier strings, including the elements index. We can then specify the metamorphic relation by setting the data source and giving the relation a new name.

def generate_data_with_identifiers(n=20):
    """
    Data generation function, returns a list of tuples with random integers and unique identifiers.
    """
    data = []
    for i in range(n):
        value = random.randint(1, 100)
        identifier = f"id_{i}"
        data.append((value, identifier))
    return data

# Generate a list of lists by running the data generation function 20 times
generated_data = [generate_data_with_identifiers() for _ in range(20)]

remove_element_tight = gmt.create_metamorphic_relation("remove_element_tight", data=generated_data)

Transformation Function

In the transformation function, we use the gmt.randomized decorator to sample randomly from the list indexes. We create a follow-up input by excluding the sampled index from the original source list.

@gmt.general_transformation(remove_element_tight)
@gmt.randomized("id_to_remove", gmt.RandInt(0,19))
def remove_random_element(mtc: gmt.MetamorphicTestCase, id_to_remove: int):
    """
    Removes a random element from the source input list
    """
    identifier_to_remove = f"id_{id_to_remove}"

    source_input = mtc.source_input

    modified_input = [item for item in source_input if item[1] != identifier_to_remove]

    return [modified_input]

Relation Function

The source and follow-up outputs are the sorted lists. Removing the exact same identifier from the sorted source output list should result in precisely the same array as the follow-up output.

@gmt.general_relation(remove_element_tight)
def tight_oracle_removed_element(mtc: gmt.MetamorphicTestCase):
    """
    Verifies that the element with the specified identifier gets
    removed and the output is exactly the same otherwise.
    """
    source_output = mtc.source_output
    followup_output = mtc.followup_output

    id = mtc.parameters["id_to_remove"]

    identifier_to_remove = f"id_{id}"

    modified_source_output = [item for item in source_output if item[1] != identifier_to_remove]

    return modified_source_output == followup_output

System Under Test

The systems under test are three stable sorting algorithms. The stable property of those algorithms ensures that the elements maintain their relative order even when the values are equal. This property is essential for validating the equality of our outputs in the relation function. For unstable algorithms, comparing only equality in values would be necessary.

@gmt.system_under_test()
def test_insertionSort(list):
    result = insertionSort(list)
    return result


@gmt.system_under_test()
def test_mergeSort(list):
    result = mergeSort(list)
    return result


@gmt.system_under_test()
def test_radixSort(list):
    result = radixSort(list)
    return result

Predefined Parameters

List Generation, MR creation and defining the Parameters

Again, we generate the lists as in the previous example. Additionally, we create a dictionary that holds the parameters we want to use in our transformations and relations. The dictionary needs to be passed as an argument during the creation of the metamorphic relation.

def generate_data_with_identifiers(n=20):
    """
    Data generation function, returns a list of tuples with random integers and unique identifiers.
    """
    data = []
    for i in range(n):
        value = random.randint(1, 100)
        identifier = f"id_{i}"
        data.append((value, identifier))
    return data

# Generate a list of lists by running the data generation function 20 times
generated_data = [generate_data_with_identifiers() for _ in range(20)]

parameters_dict = {
"remove_index": [f"id_{random.randint(0, 19)}"]
}

remove_element_tight = gmt.create_metamorphic_relation(
"remove_element_tight", data=generated_data, parameters=parameters_dict
)

Transformation Function

In the transformation function, we use the gmt.randomized decorator to sample randomly from the list indexes. We create a follow-up input by excluding the sampled index from the original source list.

@gmt.general_transformation(remove_element_tight)
def remove_random_element(mtc: gmt.MetamorphicTestCase,):
"""
Removes a random element from the source input list
"""
identifier_to_remove = f"id_{remove_index}"

source_input = mtc.source_input

modified_input = [item for item in source_input if item[1] != identifier_to_remove]

return [modified_input]

Relation Function

The source and follow-up outputs are the sorted lists. Removing the exact same identifier from the sorted source output list should result in the same array as the follow-up output in terms of the list values.

@gmt.general_relation(remove_element_tight)
def tight_oracle_removed_element(mtc: gmt.MetamorphicTestCase):
    """
    Verifies that the element with the specified identifier gets
    removed and the output values are the same otherwise.
    """
    source_output = mtc.source_output
    followup_output = mtc.followup_output

    id = mtc.parameters["id_to_remove"]

    identifier_to_remove = f"id_{id}"

    modified_source_values = [
        item[0] for item in source_output if item[1] != identifier_to_remove
    ]

    followup_values = [item[0] for item in followup_output]

    # Check if the values match
    return modified_source_values == followup_values

System Under Test

The systems under test are three unstable sorting algorithms. Therefore, we can not guarantee equality in the identifiers for the two lists. The relation function verifies the equality of the sorted values instead.

@gmt.system_under_test()
def test_heapSort(list):
    return heapSort(list)


@gmt.system_under_test()
def test_quickSort(list):
    return quickSort(list, 0, len(list) - 1)


@gmt.system_under_test()
def test_selectionSort(list):
    return selectionSort(list, len(list))