Unit Tests with TDD4C

image-20240112-190440.png

This article discusses the use of unit tests in Test Driven Development for Consulting (TDD4C). It explains why and when to write unit tests and provides samples of unit tests for different scenarios. The article also covers how to test element results, test functions within elements, and more complex test definitions. It includes examples of mocking API functions, mocking the "api.stream" function, mocking other element results, mocking API variables, mocking functions from shared libraries, and testing functions that modify tables and always return null. Overall, the article aims to provide guidance on writing effective unit tests in TDD4C.

TDD4C stands for Test Driven Development 4 Consulting. It is a tiny project that allows logics (formulas) to be run locally in your favorite Java IDE or on any Continuous Integration instance (e.g. Jenkins, Team City, ect.). The main goal is to provide simple and easy way of writing unit tests for existing and newly created logics. 

LEARN MORE: To understand more about TDD4c, click here. This article is focused mainly on sample of unit tests when TDD4C is already deployed on project.

Why and When to Write Unit Tests?

The guidelines for unit testing in TDD4C are as follows:

  • Aim for a balance between comprehensive logic coverage and practicality, considering the size and longevity of the application, as well as developer turnover.

  • Adapt the level of unit testing to the frequency and scale of change management, with more extensive testing for applications undergoing frequent and significant changes.

  • Tailor the unit test coverage to the impact of broken features, ensuring higher coverage for critical functions that could result in financial losses or data exposure.

More standard approaches to unit testing:

  • On one end of the spectrum, there is the approach advocating for 100% logic coverage through unit tests, suggesting that every if statement and edge case should be tested.

  • On the opposite end, there is the perspective of avoiding unit tests altogether due to the time-consuming nature of refactoring and maintaining tests. This approach argues that the time and effort required for testing outweigh the benefits of developing new features and addressing bugs.

Ideally you should be as closest as possible to the first approach, but you have to consider:

  1. Size of the application, when considering the size of the application, it's important to assess whether it is a small, one-time use app or a larger, long-term application with multiple modules. For applications with longevity and potential developer turnover, it is advisable to have a higher number of unit tests

  2. Change management frequency, change management frequency also plays a role, with the level of unit testing being adjusted based on the frequency and scale of changes.

    1. If there are no plans to change the application or if only small changes are anticipated, a limited number of unit tests may be deemed sufficient.

    2. In the case of a few change requests every week and significant interventions to the current logic, it is essential to ensure that no functionality is inadvertently broken.

  3. Impact of broken feature, should inform the extent of unit test coverage, with critical functions requiring higher coverage to mitigate potential financial losses and data exposure.

    1. If the features are limited to display options, non-critical business data, or non-critical reports, lower unit test coverage may be deemed acceptable.

    2. When a bug can result in financial losses, negative margins, or the exposure of critical customer data, it is crucial to ensure thorough testing of the affected areas.

Samples of Unit Tests

We will explore these two types of unit testing:

  • Element Results

  • Function Testing

How To Test Element Result

In Cypress, testing an element result typically involves using Cypress commands to locate and interact with specific elements in the application under test. Here's a summary of how to test an element result in Cypress:

  • Locate the Element: Use Cypress commands such as cy.get(), cy.contains(), or cy.find() to locate the element you want to test. You can use CSS selectors, XPath, or other methods to target the element.

  • Interact with the Element: Once the element is located, you can interact with it using Cypress commands like click(), type(), or invoke() to simulate user actions such as clicking, typing, or invoking specific behaviors.

  • Assert the Result: After interacting with the element, use Cypress assertions such as should() to verify the expected result. For example, you can assert that the element contains specific text, has a certain class, or is visible on the page.

  • Handle Asynchronous Behavior: Cypress handles asynchronous behavior automatically, so you can chain commands and assertions to ensure that the test waits for the element result before proceeding.

  • View Test Results: When running the test in the Cypress Test Runner, you can view the test results in real-time, including any passed or failed assertions related to the element result.

By following these steps, you can effectively test element results in Cypress, ensuring that the application's UI behaves as expected and that specific elements produce the desired outcomes.

Overview of Element Result Test

Element result test are tests checking the result of the element itself. This is suitable for elements that returns any value in priceList, LPG or any other logic.

For this sample, let's have simple elements called “FinalPriceEvolPct”. It returns either evolution value in percent or null if conditions are not satisfied. Its result is dependent on result of another elements.

Element code is this:

if (out.IsResultElementNewPME) { def newPME = out.NewPME def basePME = out.BasePME if(newPME != null && basePME !=null && basePME !=0){ return (newPME / basePME) - 1 } } else if(out.IsResultElementNewListPrice){ def newListPrice = out.NewListPrice def baseListPrice = out.BaseListPrice if(newListPrice !=null && baseListPrice !=null && baseListPrice !=0){ return (newListPrice / baseListPrice) - 1 } } return null

NOTE: Here's a breakdown of what the code is doing:

  • The code first checks if the result element IsResultElementNewPME is true. If so, it defines newPME and basePME using values from the out object.

  • If both newPME and basePME are not null, and basePME is not zero, it calculates the percentage change by dividing newPME by basePME and subtracting 1. The result of this calculation is then returned.

  • If the first condition is not met, the code checks another condition: if IsResultElementNewListPrice is true. If this is the case, it defines newListPrice and baseListPrice from the out object.

  • Similarly to the first condition, if both newListPrice and baseListPrice are not null and baseListPrice is not zero, it calculates the percentage change by dividing newListPrice by baseListPrice and subtracting 1. This result is then returned.

  • If neither condition is met, the code returns null.

CODE REVIEW: The percentage change calculation formula used here is a common method to determine the relative change between a new and a base value. The formula used is:

Percentage Change=(New ValueBase Value)−1Percentage Change=(Base ValueNew Value​)−1

This code is likely part of a larger program that deals with financial or pricing data, as indicated by the variables PME (which could stand for something like 'Price Market Estimate' or similar) and ListPrice. The script ensures that division by zero does not occur and handles null values to prevent runtime errors.

Running Element Result Test

Next, we will test for this element to check what happens if all other elements are null. Expected result is null. Other elements referred in code like “out.NewPME” are defined to be null in function “.withLogicTestDoubles"

def "FinalPriceEvolPct returns null if all inputs are null for NewPME result element"() { when: TestRun testRun = TestRun.builder() .withLogicTestDoubles("api" : [], out: [ "NewPME": null, "BasePME":null, "IsResultElementNewPME":true ]) .buildElementTest(LOGIC_DIR, ELEMENT_NAME) then: testRun.execute() .getElementTestResult() == null }

 

Here is the breakdown of the creating the testing scenario:

  • The test is structured using a fluent API style, common in testing frameworks like Spock, which Groovy often uses.

  • The test case is built using a TestRun.builder() method chain, which sets up the test doubles for an "api"

  • The output expectations as null for "NewPME" and "BasePME", with "IsResultElementNewPME" set to true.

  • After building the test with .buildElementTest(LOGIC_DIR, ELEMENT_NAME), it proceeds to the execution phase with testRun.execute()

  • Then it asserts that the result of the function under test should be null with .getElementTestResult() == null.

This test ensures that the function behaves correctly in scenarios where it should not perform any calculations or return any values due to the absence of input data.

Element Result Modifications

Next, we can do some additional modifications by modifying the input values NewPme, BasePME and IsResultElementNewPME and check the output value. If anybody will do a change in this element in future, we will identify it very quickly as test will fail.

This sample shows if NewPME is equal to 100, BasePME is equal to 20 and IsResultElementNewPME is equal to true, result must be 4.

def "FinalPriceEvolPct returns calculated value for NewPME result element"() { when: TestRun testRun = TestRun.builder() .withLogicTestDoubles("api" : [], out: [ "NewPME": 100, "BasePME":20, "IsResultElementNewPME":true ]) .buildElementTest(LOGIC_DIR, ELEMENT_NAME) then: testRun.execute() .getElementTestResult() == 4 }

 

This example is part of a behavior-driven development (BDD) framework, where the test cases are written in a human-readable format. The "when" block describes the setup or conditions under which the test is executed, and the "then" block describes the expected outcome.

The code snippet is intended to be self-contained and descriptive of its purpose, which allows for easier understanding and maintenance of the test case. It's a good example of how modern testing frameworks allow for readable and maintainable test definitions.

Creating Additional Element Result Tests

It's advisable to write the remaining tests for the other combinations of input parameters:

  • what happens if NewPME is 100and BasePME 20?

  • what happens if BasePME is null and NewPME 100?

  • what happens if BasePME is -1000 and NewPME -200?

  • what happens if IsResultElementNewPME is null and NewPME is 100and BasePME 20?

  • and so on…

In a scenario where you only modify the test input and test result while keeping the rest of the test the same, you have two options: you can either write a standalone test for each combination of input and result, or you can write a single test with a "where" clause that defines the input-output combinations.

Let’s use the following sample:

This snippet of code written in Groovy, defines a function named "FinalPriceEvolPct" that is intended to return a calculated value for a result element labeled "NewPME."

From this code snippet we can deduce the following:

  • The function begins with the declaration and then sets up a TestRun object using a builder pattern. The TestRun object is configured with an empty array for "api" and a map for the "out" parameter, which includes "NewPME," "BasePME," and "IsResultElementNewPME" as keys with corresponding variables that are expected to be provided when the test is executed.

  • The .buildElementTest(LOGIC_DIR, ELEMENT_NAME) method call suggests that this function is part of a larger testing framework where specific elements are tested against a set of logic rules defined in directories or files referenced by LOGIC_DIR and ELEMENT_NAME.

  • The then: block contains the actual test execution, where the testRun.execute() method is called, and its result is compared with an expectedResult variable using the Groovy assert statement (==).

  • The where: block defines the data that will be used for the test cases. It's structured as a table with columns for "NewPme," "BasePME," "IsResultElementNewPME," and "expectedResult." Each row represents a different test case with values provided for each column:

    • A case where "NewPme" is 100, "BasePME" is 20, and "IsResultElementNewPME" is true, expecting the result to be 4.

    • A case where "NewPme" is null, "BasePME" is 100, and "IsResultElementNewPME" is true, expecting the result to be null.

    • A case where "NewPme" is -1000, "BasePME" is -200, and "IsResultElementNewPME" is true, expecting the result to be 4.

How to Test a Function

In Cypress, testing a function involves writing test cases within a Cypress test file, using Cypress commands and assertions to exercise the function with different inputs and scenarios, and asserting the expected outcomes or behavior.

The main difference compared to an "element test" is that an element test uses the function .getElementTestResult(), while a function test uses the function .getElementScript().getRetailerSIPrice to run a method from the element itself.

To test a function in Cypress, you can follow these general steps:

  • Set Up Test File: Create a Cypress test file (typically a .spec.js file) within the "integration" folder of your Cypress project. This file will contain the test cases for your function.

  • Import Function: If the function you want to test is defined in a separate JavaScript file, import it into your test file using ES6 import syntax or any other suitable method.

  • Write Test Cases: Within the test file, use Cypress commands and assertions to write test cases that exercise the function under different scenarios. This may involve calling the function with different input values and asserting the expected output or behavior.

  • Arrange-Act-Assert: Structure your test cases using the Arrange-Act-Assert pattern. Arrange the test environment and inputs, act by calling the function, and assert the expected outcomes or side effects.

  • Use Cypress Commands: Utilize Cypress commands such as cy.wrap(), cy.invoke(), or cy.then() to interact with the function and handle asynchronous behavior if necessary.

  • Handle Mocking and Stubs: If the function interacts with external services or APIs, consider using Cypress plugins like cy.intercept() to mock API responses or stub external dependencies for isolated testing.

  • Run Tests: Use the Cypress Test Runner to run your test file and observe the results. You can view the test execution in real-time and inspect any passed or failed assertions related to the function being tested.

Testing a Function

The function definition inside an element can be tested separately to observe how the function behaves with different inputs.

For example, using the following element called RetailerSIPrice (code is not much important for you so you do not need to study it in details):

Perform Function Test

We will now create a basic test for this function. The test will verify the function's result when the input list is null using the code below:

Now, let's use a non-null input for the second test using the following code:

The execution of this test case results in:

  • Creates an instance of TestRun using a builder pattern, and seems to be setting up some kind of element test with unspecified LOGIC_DIR and ELEMENT_NAME.

  • The then part of the test asserts that when the getRetailerSIPrice method of the element script is called with retailerSIRecords, it should return 11.5.

  • This likely means that the method should calculate the average of the "RetailerSIPriceEUR" values, ignoring any records that do not have this key (as is the case with the last record).

Given that there are four valid "RetailerSIPriceEUR" records with values 10, 11, 12, and 13, their average indeed is 10+11+12+134=11.5410+11+12+13​=11.5, which is what the test expects.

Complex Test Definitions

This section covers various comples scenarios, including mocking API functions such as findLookupTable or find, mocking the "stream," mocking the result of another element, mocking api.global or api.local variables, mocking functions from a shared library, testing a function that modifies local or global tables and always returns null, and testing a function using a function from another element.

This section contains these following sample test definitions:

  • Mock api function, e.g. findLookupTable or find.

  • Mock “stream

  • Mock another element result

  • Mock api.global or api.local variables

  • Mock function from (shared) Library

  • Test function if it modifies local or global table and always returns null

  • Test function using function from another element

Mock Pricefx API functions

Mocking Pricefx API functions covers various aspects such as mocking API functions, testing function behavior with different inputs, and mocking global or local variables. It also includes examples of mocking "api.stream" and another element's result. Additionally, it addresses challenges related to function testing and provides insights into complex test definitions.

Mock findLookupTable or find (or any other)

Mocking the findLookupTable function includes examples of how to mock this function using the withLogicTestDouble function or as part of withLogicTestDoubles. It also provides insights into testing functions that modify local or global tables and always return null, as well as mocking other elements' results. Additionally, it addresses the mocking of API functions, such as findLookupTable or find, and how to mock "api.stream" and api.global or api.local variables.

For example, we can do it via withLogicTestDouble function or as part of withLogicTestDoubles function. The function withLogicTestDoubles simply contains “api” map, with mocked api functions.

 

This test case appears to validate that the method correctly retrieves and formats pricing information for different countries within specified date ranges. The use of BigDecimal is important for financial calculations to avoid precision loss associated with floating-point arithmetic.

Mock of api.stream

Mocking the api.stream function includes defining api.stream similarly to other API functions and adding ".stream()" at the end of the values list. It also provides examples of testing functions that modify local or global tables and always return null, as well as mocking another element's result.

Additionally, it addresses more complex test definitions, such as mocking API functions like findLookupTable or find, function testing using a function from another element, and mocking global or local variables.

For example, in the following sample we will define api.stream the same way as other api function, and add “.stream()” at the end of values list.

 

In this code we can discern the following:

  • The method is being tested using a TestRun object that is built with mock responses for an API. The mock API is set up to return specific values when the getMaxFindResultsLimit and stream methods are called.

  • The stream method in particular is mocked to return a list of maps, each containing attributes with keys like "attribute18", "attribute17", "attribute39", and "attribute37", and corresponding values such as "SNOW", "RIKEN", "PC", and "Winter" for one entry, and "ENERGY SAVER", "MICHELIN", "PC", and "Summer" for another.

  • The buildElementTest method seems to be used to prepare the test environment, specifying a logic directory and an element name.

The expected outcome of the method is:

  • A map with keys like "*", "SNOW", and "ENERGY SAVER", each pointing to their respective maps containing various attributes and their values.

This is a typical pattern for writing unit tests in software development, where you set up conditions, invoke the method under test, and assert that the output matches your expectations.

Mock another Element Result

This is very common case - element or functions is using another element result and you have to mock it to your rest. You can simply do it with “out” map like this. See the line 5 and below.

How to mock api.global or api.local variables

This is necessary if element or function you test is using any value from api.global or api.local table. See this example, especially lines 8 and 12. This sample defines global table, api.local table is defined the same way - just change the keyword to “local”.

How to mock function from (shared) Library

Let’s say our function is using another function from shared library and we have to mock it. You can either mock it inside test or mock it as separate function. Here you can se how to do it. See the line 1-10 and 29. Keyword you need is “libs”

 

Test Function with Local or Global table

In this section, we discuss testing a function that modifies local or global tables and always returns null. It involves creating a test case to verify the expected behavior when the function returns null, as well as checking if the function modifies the local or global table as intended. This can be accomplished by setting up the necessary input parameters and mock data for the function and then asserting that the returned result matches the expected outcome.

image-20240112-190229.png

Overview of Code

Testing an element that modifies local or global tables and always returns null may seem challenging, but it is not as difficult as it appears. So, let’s review some code that just adds a value to local map.

 

The code performs the following operations:

  1. It checks if the out.EU3LPG condition is true. If so, it proceeds to iterate over an array of country records using a for-loop.

  2. For each country record, it retrieves the country name and then fetches a corresponding record from a map called countryMatrixMap.

  3. It extracts four attributes from the country record: TargetSIPriceLowRM, TargetSIPriceHighRM, TargetSIPriceHighMaxRM, and TargetSIPriceHighMinRM.

  4. Several conditional checks are performed to determine the value of highLowRMLogic:

    • If both TargetSIPriceLowRM and TargetSIPriceHighRM are null, highLowRMLogic remains null.

    • If only TargetSIPriceLowRM is null, highLowRMLogic is set to "Adjusted to High Comp Index Target".

    • If both TargetSIPriceHighMaxRM and TargetSIPriceHighMinRM are not null, further conditions are checked to set highLowRMLogic to one of the three values: "Adjusted to Low Comp Index Target", "Adjusted to High Min Comp Index Target", or "Adjusted to High Max Comp Index Target" based on comparisons between TargetSIPriceLowRM, TargetSIPriceHighMaxRM, and TargetSIPriceHighMinRM.

  5. The resulting highLowRMLogic value is then put back into the countryMatrixMap for the respective country.

  6. Finally, the function returns null.

Test Scenario

The provided Groovy code defines a test scenario using a TestRun builder pattern to set up and execute a test for business logic. It involves the use of globalTable and localTable, which are maps containing configuration or data needed for the test.

 

Here is the sequence of events:

  • A TestRun object is created with a builder pattern, setting up some logic test doubles, which seem to be mock data or configurations for the test.

  • The "out" key is set to a map containing "EU3LPG" as true, and the "api" key is set to a map with the "global" and "local" keys mapped to the previously defined tables.

  • The test is built with unspecified LOGIC_DIR and ELEMENT_NAME variables, possibly indicating the directory and name of the logic element being tested.

  • After building the test element, the test is executed, and it seems to expect that the result of the test execution should be null (getElementTestResult() == null).

  • Finally, there is an assertion that after the test execution, the localTable should have been modified such that the entry for France now includes a "HighLowRMLogic" key with the value "Adjusted to High Comp Index Target", while the entry for Romania should have a "HighLowRMLogic" key with a null value.

Test Element or Function using Function from another Element

 

This section discusses an element that uses a function from another element, specifically involving Groovy version compatibility. It highlights that the functionality works with Groovy 2.4.12 but not with higher versions.

Also, it highlights the impact on testing in different environments, such as on a GitLab server versus a local machine and identifies potential projects that may be affected by this compatibility issue. This issue suggests that an update to TDD4C is needed to work with newer versions of Groovy. This information provides insights into the challenges related to version compatibility and testing in specific software development projects.

Let’s have element (simplified) using function from another element (line 3, element Library in the same logic).

Groovy Version Incompatibility

Let’s discuss the compatibility of a test method when using Groovy in different versions, using code example above, the method works with Groovy version 2.4.12, but does not work with higher versions. This implies that the method is compatible with Groovy 2.4.12 and lower, but not with higher versions.

The conclusion is that those tests work on gitlab server in pipeline but usually don’t work on Configuration Engineer’s localhost. Once the server is upgraded to a higher version of Groovy, it will cause some tests to fail in one or more projects.

Allow me to provide a quick description of the scenario, I have the following element test show below:

Lets review the actual active code and highlight the events:

  • A TestRun object is being built with specific test doubles. These doubles are mock values that represent the inputs or conditions under which the system under test operates.

  • It will use keys such as "AdjustmentFactorVs", "CurrentPME", "BasePME", "IncreaseThresholdPct", "DecreaseThresholdPct", "ProposedPME", and "IsResultElementNewPME" as variables or parameters that the Price Management Engine uses to calculate the final price.

  • The withLogicTestDoubles method is used to provide these mock values, and withAdditionalElementInitialized("Library") suggests that an additional component or library is being initialized for this test.

  • Finally, the "then" block asserts that the result of executing the test (testRun.execute().getElementTestResult()) should equal 10.

  • This assertion checks whether the output of the Price Management Engine matches the expected value, given the test conditions.

Illustrating Groovy Incompatibility

Now, let’s describe an issue with a test failing in Groovy versions higher than 2.4.12 due to a method not being found in the Library element, which seems to be improperly initialized. We will also assert that mocking the function via withLogicTestDoubles and using the function withAdditionalElementInitialized does not work properly.

Here is the incompatibility scenario:

  • Using an element with following code (simplified):

  • As element calls function from another element, I have also element Library with following function (simplified):

  • In Groovy higher than 2.4.12 the test will fail with following error: