All in One View

Content from What is a Unit Test


Last updated on 2026-05-05 | Edit this page

Overview

Questions

  • What is unit testing?
  • Why do we need unit tests?

Objectives

  • Define the key aspects of a good unit test (isolated, testing minimal functionality, fast, etc).
  • Understand the key anatomy of a unit test in any language.
  • Explain the benefit of unit tests on top of other types of tests.
  • Understand when to run unit tests.

Unit testing is a way of verifying the validity of a code base by testing its smallest individual components, or units.

“If the parts don’t work by themselves, they probably won’t work well together” – (Thomas and Hunt, 2019, The pragmatic programmer, Topic 51).

Several key aspects define a unit test. They should be…

  • Isolated - Does not rely on any other unit of code within the repository.
  • Minimal - Tests only one unit of code.
  • Fast - Run on the scale of ms or s.
Callout

Other forms of testing

There are other forms of testing, such as integration testing in which two or more units of a code base are tested to verify that they work together, or that they are correctly integrated. However, today we are focusing on unit tests as it is often the case that many of these larger tests are written using the same test tools and frameworks, hence we will make progress with both by starting with unit testing.

What does a unit test look like?


All unit tests tend to follow the same pattern of Given-When-Then.

  • Given we are in some specific starting state
    • Units of code almost always have some inputs. These inputs may be scalars to be passed into a function, but they may also be an external dependency such as a database, file or array which must be allocated.
    • This database, file or array memory must exist before the unit can be tested. Hence, we must set up this state in advance of calling the unit we are testing.
  • When we carry out a specific action
    • This is the step in which we call the unit of code to be tested, such as a call to a function or subroutine.
    • We should limit the number of actions being performed here to ensure it is easy to determine which unit is failing in the event that a test fails.
  • Then some specific event/outcome will have occurred.
    • Once we have called our unit of code, we must check that what we expected to happen did indeed happen.
    • This could mean comparing a scalar or vector quantity returned from the called unit against some expected value. However, it could be something more complex such as validating the contents of a database or outputted file.
Challenge

Challenge 1: Write a unit test in pseudo code

Assuming you have a function reverse_array which reverses the order of an allocated array. Write a unit test in pseudo code for reverse_array using the pattern above.

TXT

! Given
Allocate the input array `input_array`
Fill `input_array`, for example with `(1,2,3,4)`
Allocate the expected output array `expected_output_array`
Fill `expected_output_array` with the correct expected output, i.e., `(4,3,2,1)`

! When
Call `reverse_array` with `input_array`

! Then
for each element in `input_array`:
   Assert that the corresponding element of `expected_output_array` matches that of `input_array`

When should unit tests be run?


A major benefit of unit tests is the ability to identify bugs at the earliest possible stage. Therefore, unit tests should be run frequently throughout the development process. Passing unit tests give you and your collaborators confidence that changes to your code aren’t modifying the previously expected behaviour, so run your unit tests…

  • if you make a change locally
  • if you raise a merge request
  • if you plan to do a release
  • if you are reviewing someone else’s changes
  • if you have recently installed your code into a new environment
  • if your dependencies have been updated

Basically, all the time.

Do we really need unit tests?


Yes!

You may be thinking that you don’t require unit tests as you already have some well-defined end-to-end test cases which demonstrate that your code base works as expected. However, consider the case where this end-to-end test begins to fail. The message for this failure is likely to be something along the lines of

TXT

Expected my_special_number to be 1.234 but got 5.678

If you have a comprehensive understanding of your code, perhaps this is all you need. However, assuming the newest feature that caused this failure was not written by you, it’s going to be difficult to identify what is going wrong without some lengthy debugging.

Now imagine the situation where this developer added unit tests for their new code. When running these unit tests, you may see something like

TXT

test_populate_arrays Failed: Expected 1 for index 1 but got 0

This is much clearer. We immediately have an idea of what could be going wrong and the unit test itself will help us determine the problematic code to investigate.

Challenge

Challenge 2: Unit test bad practices

Take a look at 1-into-to-unit-tests/challenge in the exercises repository.

A solution is provided in 1-into-to-unit-tests/solution.

References


Content from Refactoring Fortran


Last updated on 2026-05-05 | Edit this page

Overview

Questions

  • What does good Fortran code look like?
  • How do I refactor Fortran code to follow best practices?

Objectives

  • Be able to spot bad practice within Fortran code.
  • Understand why following best practice make Fortran more testable.

Within Fortran projects, it is common to find many instances of bad practice which makes it difficult, if not impossible to implement unit tests. Therefore, in many cases, the first step to writing unit tests for a Fortran project is to refactor some section of the code into a more testable state which follows best practice. Examples of what we mean by “bad practice” would be not limited to but could include…

  • Using global variables.
  • Large, multi-purpose procedures.
  • Undocumented variables, procedures, modules and programs.

To demonstrate the benefits of refactoring Fortran and how it can be done, we’re going to help John to improve his Fortran implementation of the game of life. A copy of John’s code can be found in the exercises repo at 2-refactoring-fortran/challenge.

Conway’s Game of life is a cellular automaton devised by the British mathematician John Horton Conway in 1970 (Gardner, 1970).

The universe of the Game of Life is an infinite, two-dimensional orthogonal grid of square cells, each of which is in one of two possible states, live or dead (or populated and unpopulated, respectively). Every cell interacts with its eight neighbours, which are the cells that are horizontally, vertically, or diagonally adjacent. At each step in time, the following transitions occur:

  1. Any live cell with fewer than two live neighbours dies, as if by underpopulation.
  2. Any live cell with two or three live neighbours lives on to the next generation.
  3. Any live cell with more than three live neighbours dies, as if by overpopulation.
  4. Any dead cell with exactly three live neighbours becomes a live cell, as if by reproduction.

See the Wikipedia article for more details.

Callout

Checking we haven’t broken anything

To ensure we don’t break anything during our refactoring we need to have some way to test our code. Since we don’t have any automated tests in place we will need to do this manually. Firstly, let’s generate a starting state which we know to be correct.

SH

cd episodes/7-refactoring-fortran/challenge
cmake -B build
cmake --build build
./build/game-of-life ../models/model-1.dat > initial-state.out

Then, whenever we make a change, we can test if the code still works as expected

SH

cmake --build build
./build/game-of-life ../models/model-1.dat > new-state.out
diff initial-state.out new-state.out

If there are no differences, we can assume we haven’t broken anything.

The known refactorings


The next few sections will present some known refactorings.

We’ll show before and after code, present any new coding techniques needed to do the refactoring, and describe code smells - how you know you need to refactor.

1. Replace magic numbers with constants

Smells

  • Raw numbers appear in your code.

Benefits

  • When we use constant with a clear name, it is instantly clear what that value represents.
  • If we use a constant in more than one place, when that value needs to be changed, there is only one place we need to make an update.

F90

do i = 1, 100
    x = i * 3.141 / 100.0
    data(i) = sin(x)
end do

F90

do i = 1, resolution
    x = i * pi / real(resolution)
    data(i) = sin(x)
end do
Challenge

Challenge

Replace all magic numbers in John’s game of life code with constants.

This can be achieved with the changes shown in this commit

2. Change of variable name

Smells

  • Code needs a comment to explain what it is for.

Benefits

  • Someone reading your code can instantly understand what a variable represents and is much more likely to understand the logic employed.

F90

a = a + b*dt

F90

velocity = velocity + acceleration * dt
Challenge

Challenge

Update any poorly named variables in John’s code to have clear names which make it clear what they are.

This can be achieved with the changes shown in this commit

3. Break large procedures into smaller units

Smells

  • A function or subroutine no longer fits on a page in your editor.
  • Multiple dummy arguments are updated (i.e. multiple intent(out) arguments)
  • A line of code is deeply indented
  • A piece of code interacts with the surrounding code through just a few variables.

Benefits

  • Procedures with only one purpose will be much easier to fix should a bug be introduced.
  • Unit testing becomes easier as there are less input/output variables and scenarios to consider when writing your tests.

F90

module process_marices_mod
    implicit none
    real, allocatable :: A(:,:), B(:,:), C(:,:)

contains
    subroutine process_matrices(filename)
        character(len=*), intent(in) :: filename
        integer :: n, iostat, i, j, k
        integer :: unit
        real :: trace

        open(newunit=unit, file=filename, status='old', action='read', iostat=iostat)
        if (iostat /= 0) then
            print *, 'Error opening file: ', trim(filename)
            stop
        end if

        read(unit, *, iostat=iostat) n
        if (iostat /= 0) stop 'Error reading matrix size.'

        allocate(A(n,n), B(n,n))

        print *, 'Reading matrix A (', n, 'x', n, ')'
        do i = 1, n
            read(unit, *, iostat=iostat) (A(i,j), j=1,n)
            if (iostat /= 0) stop 'Error reading matrix A.'
        end do

        print *, 'Reading matrix B (', n, 'x', n, ')'
        do i = 1, n
            read(unit, *, iostat=iostat) (B(i,j), j=1,n)
            if (iostat /= 0) stop 'Error reading matrix B.'
        end do

        close(unit)

        C = 0.0
        do i = 1, n
            do j = 1, n
                do k = 1, n
                    C(i,j) = C(i,j) + A(i,k) * B(k,j)
                end do
            end do
        end do

        n = size(C, 1)
        trace = 0.0
        do i = 1, n
            trace = trace + C(i,i)
        end do

        print *, 'Trace of matrix C = ', trace
    end subroutine process_matrices
end module process_marices_mod

F90

module process_marices_mod
    implicit none
    real, allocatable :: A(:,:), B(:,:), C(:,:)

contains

    subroutine read_matrices_from_file(filename)
        character(len=*), intent(in) :: filename
        integer :: n, iostat, i, j
        integer :: unit

        open(newunit=unit, file=filename, status='old', action='read', iostat=iostat)
        if (iostat /= 0) then
            print *, 'Error opening file: ', trim(filename)
            stop
        end if

        read(unit, *, iostat=iostat) n
        if (iostat /= 0) stop 'Error reading matrix size.'

        allocate(A(n,n), B(n,n))

        print *, 'Reading matrix A (', n, 'x', n, ')'
        do i = 1, n
            read(unit, *, iostat=iostat) (A(i,j), j=1,n)
            if (iostat /= 0) stop 'Error reading matrix A.'
        end do

        print *, 'Reading matrix B (', n, 'x', n, ')'
        do i = 1, n
            read(unit, *, iostat=iostat) (B(i,j), j=1,n)
            if (iostat /= 0) stop 'Error reading matrix B.'
        end do

        close(unit)
    end subroutine read_matrices_from_file

    subroutine multiply_matrices()
        integer :: i, j, k, n
        n = size(A, 1)

        allocate(C(n,n))

        C = 0.0
        do i = 1, n
            do j = 1, n
                do k = 1, n
                    C(i,j) = C(i,j) + A(i,k) * B(k,j)
                end do
            end do
        end do
    end subroutine multiply_matrices

    subroutine display_trace()
        integer :: i, n
        real :: trace

        n = size(C, 1)
        trace = 0.0
        do i = 1, n
            trace = trace + C(i,i)
        end do

        print *, 'Trace of matrix C = ', trace
    end subroutine display_trace
end module process_marices_mod
Challenge

Challenge

Update John’s code to reduce the responsibilities of any procedures to one

This can be achieved with the changes shown in this commit

4. Wrap program functionality in procedures

Smell

  • Logic is repeated outside a procedure.
  • Loops appear outside a procedure.
  • Lots of inline comments requited to explain what is happening in the main program.

Benefits

  • More of your code can be tested.
  • It becomes harder to introduce side effects which may impact other aspects of your code.

F90

program my_matrix_prog
    use process_marices_mod, only : process_matrices
    implicit none

    character(len=200) :: temp_string
    character(:), allocatable :: filename


    print *, 'Enter input filename:'
    read (*,*) temp_string
    filename = trim(temp_string)

    call process_matrices(filename)

end program my_matrix_prog

F90

program my_matrix_prog
    use process_marices_mod, only : process_matrices
    implicit none

    character(:), allocatable :: filename

    call read_filename(filename)
    call process_matrices(filename)

contains

    subroutine read_filename(filename)
        character(:), allocatable, intent(out) :: filename

        character(len=200) :: temp_string

        print *, 'Enter input filename:'
        read (*,*) temp_string

        filename = trim(temp_string)
    end subroutine read_filename

end program my_matrix_prog
Challenge

Challenge

Update John’s code to reduce the responsibilities of any procedures to one

This can be achieved with the changes shown in this commit

5. Replace repeated code with a procedure

Smells

  • Fragments of repeated code appear.

Benefits

  • If logic needs to be updated in the future, there is now just one place this needs to be done
  • More of your code can be unit tested.

F90

subroutine read_matrices_from_file(filename)
    character(len=*), intent(in) :: filename
    integer :: n, iostat, i, j
    integer :: unit

    open(newunit=unit, file=filename, status='old', action='read', iostat=iostat)
    if (iostat /= 0) then
        print *, 'Error opening file: ', trim(filename)
        stop
    end if

    read(unit, *, iostat=iostat) n
    if (iostat /= 0) stop 'Error reading matrix size.'

    allocate(A(n,n), B(n,n))

    print *, 'Reading matrix A (', n, 'x', n, ')'
    do i = 1, n
        read(unit, *, iostat=iostat) (A(i,j), j=1,n)
        if (iostat /= 0) stop 'Error reading matrix A.'
    end do

    print *, 'Reading matrix B (', n, 'x', n, ')'
    do i = 1, n
        read(unit, *, iostat=iostat) (B(i,j), j=1,n)
        if (iostat /= 0) stop 'Error reading matrix B.'
    end do

    close(unit)
end subroutine read_matrices_from_file

F90

subroutine read_matrices_from_file(filename)
    character(len=*), intent(in) :: filename
    integer :: n, iostat, i, j
    integer :: unit

    open(newunit=unit, file=filename, status='old', action='read', iostat=iostat)
    if (iostat /= 0) then
        print *, 'Error opening file: ', trim(filename)
        stop
    end if

    read(unit, *, iostat=iostat) n
    if (iostat /= 0) stop 'Error reading matrix size.'

    allocate(A(n,n), B(n,n))

    print *, 'Reading matrix A (', n, 'x', n, ')'
    call read_next_matrix_from_file(A, unit)

    print *, 'Reading matrix B (', n, 'x', n, ')'
    call read_next_matrix_from_file(B, unit)

    close(unit)
end subroutine read_matrices_from_file

subroutine read_next_matrix_from_file(matrix, unit)
    real, allocatable, intent(inout) :: matrix(:,:)
    integer, intent(in) :: unit

    integer :: i, j, iostat, n

    n = size(matrix, 1)

    do i = 1, n
        read(unit, *, iostat=iostat) (matrix(i,j), j=1,n)
        if (iostat /= 0) stop 'Error reading matrix.'
    end do
end subroutine read_next_matrix_from_file
Callout

There’s a delicate balance between reducing code repetition and make your code unreadable. Try not to go too far when refactoring!

Challenge

Challenge

Update John’s code to move any repeated code into a procedure.

This can be achieved with the changes shown in this commit

6. Replace global variables with procedure arguments

Smells

  • A global variable is assigned and then used inside a called function.
  • A variable is edited within a procedure in which it is not declared.

Benefits

  • Testing becomes much easier because your code is more isolated and thus less code is required within your tests to setup state.
  • You get more help from your compiler and it t is much clearer what your code is doing as you can provide more information about dummy arguments such as their intent.

F90

subroutine multiply_matrices()
    integer :: i, j, k, n
    n = size(A, 1)

    allocate(C(n,n))

    C = 0.0
    do i = 1, n
        do j = 1, n
            do k = 1, n
                C(i,j) = C(i,j) + A(i,k) * B(k,j)
            end do
        end do
    end do
end subroutine multiply_matrices

F90

subroutine multiply_matrices(A, B, C)
    real, allocatable, intent(in) :: A(:,:), B(:,:)
    real, allocatable, intent(out) :: C(:,:)

    integer :: i, j, k, n
    n = size(A, 1)

    allocate(C(n,n))

    C = 0.0
    do i = 1, n
        do j = 1, n
            do k = 1, n
                C(i,j) = C(i,j) + A(i,k) * B(k,j)
            end do
        end do
    end do
end subroutine multiply_matrices
Challenge

Challenge

Update John’s code to replace any global variables accessed within procedures with dummy arguments.

This can be achieved with the changes shown in this commit

7. Separate code concepts into files or modules

Smells

  • You find it hard to locate a piece of code.
  • You get a lot of version control conflicts.

Benefits

  • This adds further clarity about what each unit of code is responsible for.
  • Allows further isolation of code as you can scope some procedures or variables to be private.

Using the example we have seen so far, we start with two files my_matrix_prog.f90 and process_marices_mod.f90.

TXT

|-- project/directory/
    |-- my_matrix_prog.f90
    |   |-- subroutine read_filename
    |-- process_marices_mod.f90
        |-- subroutine read_matrices_from_file
        |-- subroutine read_next_matrix_from_file
        |-- subroutine multiply_matrices
        |-- subroutine display_trace

If we split the procedures in these files across multiple modules which focus on different tasks, we could end up with something like this.

TXT

|-- project/directory/
    |-- my_matrix_prog.f90
    |-- io.f90
    |   |-- subroutine read_filename
    |   |-- subroutine read_matrices_from_file
    |   |-- subroutine read_next_matrix_from_file
    |-- matrix_operations.f90
        |-- subroutine multiply_matrices
        |-- subroutine display_trace

Note: there isn’t one correct way to group these subroutines. For example, we could place display_trace in io.f90.

Challenge

Challenge

Update John’s code to separate code concepts into modules.

You should end up with a module structure. For example, like this:

TXT

|-- src/
    |-- main.f90
    |-- animation.f90
    |   |-- subroutine draw_board
    |-- cli.f90
    |   |-- subroutine read_cli_arg
    |-- game_of_life.f90
    |   |-- subroutine find_steady_state
    |   |-- subroutine evolve_board
    |   |-- subroutine check_for_steady_state
    |-- io.f90
        |-- subroutine read_model_from_file

This can be achieved with the changes shown in this commit

Callout

Working effectively with legacy code

When working with Fortran it is common that you will be working with legacy code and a large scale refactor can feel daunting. Therefore, a great resource for us is Working Effectively with Legacy Code (Feathers, 2004)

If you don’t have time to read the entire book, there is a good summary of the key point in this blog post The key points of Working Effectively with Legacy Code

References


Content from Writing your first unit test


Last updated on 2026-05-05 | Edit this page

Overview

Questions

  • What does a unit test look like?

Objectives

  • Understand the benefits of parameterized tests.
  • Able to write a unit test which is isolated, minimal and fast.

The key aspects of a unit test are the same no matter the language being testing (python, Fortran, etc) or the framework we are using (pFUnit, etc). Therefore, when we are first learning unit testing, it can be useful to think about what the content of a unit test might look like before we try to learn the specific syntax of any one tool.

Testing the temperature


We’ll now use an example Fortran library which converts between units of temperature. This code can be found in the exercises repo under 3-writing-your-first-unit-test/challenge/src/temp_conversions.f90. This library contains two functions, one to convert from Fahrenheit to Celsius (fahrenheit_to_celsius) and another to convert from Celsius to Kelvin (celsius_to_kelvin).

Imagine we want to use this library to do some temperature conversions from Fahrenheit to Kelvin. To ensure the library contains the functionality we need, we decide to write some unit tests.

Challenge

Challenge: Pseudo test

Write a unit test in pseudocode for the temperature library to check that it can convert from Fahrenheit to Kelvin.

Your test could look something like this:

TXT

Set some input value of Fahrenheit, for example 32.0
Call fahrenheit_to_celsius with this input
Check that the output is equal to the expected value of 0.0

Set some input value of Celsius, for example 0.0
Call celsius_to_kelvin with this input
Check that the output is equal to the expected value of 273.15

Writing a test


All unit tests tend to follow a similar pattern.

  1. Define the inputs to your unit of code to be tested as well as the outputs you expect from execution with these inputs.

  2. Setup and verify any state required for successful execution (verify a file exists, allocate memory, etc)

  3. Call the unit of code to be tested using the inputs defined in the first step.

  4. Verify the actual outputs of your unit of code with the expected outputs defined in the first step.

Challenge

Challenge: Standard Fortran test

Write a unit test in standard Fortran for the temperature library to check that it can convert from Fahrenheit to Kelvin. You can use your pseudocode as a starting point.

As we are not yet using a testing framework, some boilerplate code has been provided to help you create a test-suite. Take a look at part one of the exercise 3-writing-your-first-unit-test/challenge.

A solution is provided in 3-writing-your-first-unit-test/solution.

Content from pFUnit basics


Last updated on 2026-05-26 | Edit this page

Overview

Questions

  • What is the syntax of writing a unit test in Fortran?
  • How do I build my tests with my existing build system?

Objectives

  • Able to write a unit test for a Fortran procedure with test-drive, veggies and/or pFUnit.
  • Understand the similarities between each framework and where they differ.

What framework will we look at?


There are multiple frameworks available for writing unit tests in Fortran, as detailed on the Fortran Lang website. However, we recommend the use of pFUnit as it is…

  • the most feature rich framework.
  • the most widely used framework.
  • being maintained.
  • able to integrate with CMake and make.

Key features of pFUnit:

  • Supports MPI: Supports testing MPI parallelized code, including parametrizing tests by number of MPI ranks.
  • Simple interface: Tests are written in .pf format which is then pre-processed by a tool provided by pFUnit into .f90 before compilation. This removes the need to write a lot of boilerplate code.

The most basic pFUnit test


As we’ve seen in the previous episode, if we were to write our own unit tests using a custom testing setup we would need to define a test runner that could track success and failure states for each test and report the reason for each failure back to us.

Alternatively, if we were to use pFUnit, there is no longer a need to define this test runner because pFUnit handles that for us. Therefore, the most basic test we can define using pFunit becomes simple. For example, if we wanted to test the Fortran intrinsic function dot_product, we could write the following test.

FORTRAN

module test_dot_product_intrinsic
    use funit
    implicit none
contains
    @Test
    subroutine test_dot_product()
        integer :: a(10), b(10), c

        ! Define inputs and expected outputs for the scenario we want to test
        a = [1,2,3,4,5,6,7,8,9,10]
        b = [11,12,13,14,15,16,17,18,19,20]
        c = 935

        ! Check that the call to dot_product returned what we expect
        @assertEqual(c, dot_product(a, b), message="Unexpected value returned for the dot_product")

    end subroutine test_dot_product
end module test_dot_product_intrinsic

Here we have introduced some new syntax in the form of @Test and @AssertEqual. These are pFUnit pre-processor directives which simplify how we write tests:

  • @Test designates the subroutine test_dot_product as a test that should be ran on execution of your pFUnit test suite.
  • @AssertEqual is one of many assert directives provided by pFUnit. More specifically, @AssertEqual allows the exact comparison of values (also works for comparing arrays). For a full list of the available assertion directives see pFUnit documentation page for their preprocessor directives
    • As is done here, it is recommended to provide a helpful message, in case of an assertion failing, to help diagnose the issue.
Callout

@AssertEqual for floating point values

For floating point values, @AssertEqual no longer carries out an exact comparison but become a comparison up to a tolerance.

If we then wish to add a new test case we can add another subroutine, again decorated with @Test:

FORTRAN

module test_dot_product_intrinsic
    use funit
    implicit none
contains
    @Test
    subroutine test_dot_product()
        integer :: a(10), b(10), c

        ! Define inputs and expected outputs for the scenario we want to test
        a = [1,2,3,4,5,6,7,8,9,10]
        b = [11,12,13,14,15,16,17,18,19,20]
        c = 935

        ! Check that the call to dot_product returned what we expect
        @AssertEqual(c, dot_product(a, b), message="Unexpected value returned for the dot_product")

    end subroutine test_dot_product

    @Test
    subroutine test_dot_product_all_zeros()
        integer :: a(10), b(10), c

        ! Define inputs and expected outputs for the scenario we want to test
        a = 0
        b = 0
        c = 0

        ! Check that the call to dot_product returned what we expect
        @AssertEqual(c, dot_product(a, b), message="Unexpected value returned for the dot_product")

    end subroutine test_dot_product_all_zeros
end module test_dot_product_intrinsic
Challenge

Challenge: Test temperature conversions using pFUnit

Continuing with part two of 3-writing-your-first-unit-test/challenge from the exercises. Write a single test for the temperature conversion using pFUnit.

A solution is provided in 3-writing-your-first-unit-test/solution.

Content from Integrating with build systems


Last updated on 2026-05-05 | Edit this page

Overview

Questions

  • How do we go from .pf files to an executable test?
  • How do we identify which test is failing and where?

Objectives

  • Be able to add a new test to an existing Make and CMake build system.
  • Understand where we name tests within the build system.

Integrating pFUnit with Make


Let’s look at the steps required to add pFUnit tests to a project built using Make. Firstly, assume we have the following file structure.

TXT

|-- ROOT_DIR/
    | Makefile
    |-- src/
    |   |-- main.f90
    |   |-- something.f90
    |
    |-- tests/
        |-- Makefile
        |-- test_something.pf
        |-- test_something_else.pf

The top level Makefile is responsible for compiling the src code but should do little regarding building the tests. However, it should…

  • Export relevant variables for the tests/Makefile to pick up.

    BASH

    export SRC_BUILD_DIR
    export ROOT_DIR
    export SRC_OBJS
    export FC
    export FC_FLAGS
    export LIBS
  • Define targets which pass through to targets in the tests/Makefile.

    MAKEFILE

    tests: $(SRC_OBJS)
     @echo "Building pFUnit test suite..."
     @$(MAKE) -C $(TEST_DIR) tests
    
    clean:
     rm -rf $(BUILD_DIR)
     $(MAKE) -C $(TEST_DIR) clean

The full top level Makefile may look something like this:

BASH

# Top level variables
ROOT_DIR = $(shell dirname $(realpath $(firstword $(MAKEFILE_LIST))))
FC ?= gfortran
FC_FLAGS = #... Some flags required for compilation
LIBS = #... Some libs to link to

#------------------------------------#
#      Targets for compiling src     #
#------------------------------------#
SRC_DIR = $(ROOT_DIR)/src
BUILD_DIR = $(ROOT_DIR)/build

# List src files
SRC_FILES = \
    something.f90 \
    main.f90

# Map src files to .o files
SRC_OBJS = $(patsubst %.f90, $(BUILD_DIR)/%.o, $(SRC_FILES))

# Build src .o files
$(BUILD_DIR)/%.o: $(SRC_DIR)/%.f90 | $(BUILD_DIR)
 @echo "Building $@"
 $(FC) -c -J $(BUILD_DIR) -o $@ $<

# Build src executable
$(BUILD_DIR)/a.exe: $(SRC_OBJS)
 $(FC) -o $@ $(FC_FLAGS) $^ $(LIBS)

# Map exe target to building executable
exe: $(BUILD_DIR)/a.exe

# Ensure the build dirs exists
$(BUILD_DIR):
 mkdir -p $@

#------------------------------------#
#         Targets for testing        #
#------------------------------------#
TEST_DIR = $(ROOT_DIR)/tests

# Include make command from tests Makefile
tests: $(SRC_OBJS)
 @echo "Building pFUnit test suite..."
 @$(MAKE) -C $(TEST_DIR) tests


#------------------------------------#
#        Targets for cleaning        #
#------------------------------------#
# Define target for cleaning the build dir
clean:
 rm -rf $(BUILD_DIR)
 $(MAKE) -C $(TEST_DIR) clean

.PHONY: clean

#--------------------------------------#
# Export variables for child Makefiles #
#--------------------------------------#
# Export variables for the other Makefiles to use
export BUILD_DIR
export ROOT_DIR
export SRC_OBJS
export FC
export FC_FLAGS
export LIBS

The tests/Makefile would then look like this:

BASH

PFUNIT_INCLUDE_DIR ?= /path/to/pfunit/include

# Don't try to include if we're cleaning as this doesn't depend on pFUnit
ifneq ($(MAKECMDGOALS),clean)
include $(PFUNIT_INCLUDE_DIR)/PFUNIT.mk
TEST_FLAGS = -I$(BUILD_DIR) $(FC_FLAGS) $(LIBS) $(PFUNIT_EXTRA_FFLAGS)
endif

# Define variables to be picked up by make_pfunit_test
tests_TESTS = \
  test_something.pf \
  test_something_else.pf
tests_OTHER_SOURCES = $(filter-out $(BUILD_DIR)/main.o, $(SRC_OBJS))
tests_OTHER_LIBRARIES = $(TEST_FLAGS)

# Triggers pre-processing and defines rule for building test executable
$(eval $(call make_pfunit_test,tests))

# Converts pre-processed test files into objects ready for building of the executable
%.o: %.F90
 $(FC) -c $(TEST_FLAGS) $<

clean:
 \rm -f *.o *.mod *.F90 *.inc tests

Key points:

  • We must include the pre-installed pFUnit dependencies and Makefile options via the PFUNIT.mk file.
    • The version of pFUnit that has been built will affect the path to this file (i.e. …/installed/PFUNIT-4.12/include/…)
  • We are utilising the function provided by pFUnit make_pfunit_test
    • This will create a target of the provided name (in this case tests)
    • We define the variables pFUnit requires to build the tests target as variables prefixed with tests_.
      • tests_TESTS - A list of the .pf test files to be pre-processed before compilation.
      • tests_OTHER_SOURCES - A list of src object files required for the tests (excluding the src main/program file)
      • tests_OTHER_LIBRARIES - A list of library flags to pass to the compiler when compiling the test code
  • We must create a target for compiling object files which uses the same flags as tests_OTHER_LIBRARIES

We can then build and run our tests with the following commands

SH

$ make tests
...
$ ./tests/tests --verbose


 Start: <test_something_suite.test_do_something_1>
.   end: <test_something_suite.test_do_something_1>


 Start: <test_something_else_suite.test_do_something_2>
.   end: <test_something_else_suite.test_do_something_2>

Time:         0.001 seconds

 OK
 (2 tests)

Naming our tests with Make

In the output shown above we have ran using the –verbose flag. This flag includes the name of our test suites and test subroutines in the output. For example, we have 2 tests which here indicates two test functions in total, test_do_something_1 and test_do_something_2. However, we can see that these two test functions are each stored within their own test suite test_something_suite and test_something_else_suite respectively.

Here, we are defining a test suite as a single test module file (.pf file). Therefore, we can see that the name of the test suite comes from the name of the module. The name of the test is then taken from the name of the test subroutine. For example, test_something.pf would look like this.

F90

module test_something
    use something, only : do_something
    use funit
    implicit none

contains

    @Test
    subroutine test_do_something_1()
        integer :: input, actual_output

        input = 1

        call do_something(input, actual_output)

        @assertEqual(2, actual_output, "Unexpected output from do_something")
    end subroutine test_do_something_1
end module test_something
Challenge

Challenge: Practice integrating with Make

To verify your newly implemented tests of temp_conversions from the previous episode, complete part i of the building-the-test section of 3-writing-your-first-unit-test/challenge and integrate your test(s) with the Make build system provided in the exercise.

A solution is provided in 3-writing-your-first-unit-test/solution.

Integrating pFUnit with CMake


Let’s now look at the steps required to add pFUnit tests to a project built using CMake. Similar to before, let’s assume we have the following file structure.

TXT

|-- ROOT_DIR/
    | CMakeLists.txt
    |-- src/
    |   |-- main.f90
    |   |__ ... Some module files containing src code
    |
    |-- tests/
        |-- CMakeLists.txt
        |-- test_something.pf
        |-- test_something_else.pf

Just like with Make, the top level CMakeLists.txt file is responsible for compiling the src code but should do little regarding building the tests. However, it should…

  • Define a variable which stores a list of src files

    CMAKE

    set(SRC_DIR "${PROJECT_SOURCE_DIR}/src")
    set(PROJ_SRC_FILES
      "${SRC_DIR}/main.f90"
      "${SRC_DIR}/something.f90"
    )
  • Enable testing.

    CMAKE

    enable_testing()
  • Add the tests/ dir as a subdirectory.

    CMAKE

    add_subdirectory("tests")

The full top level CMakeLists.txt may look something like this:

CMAKE

cmake_minimum_required(VERSION 3.9 FATAL_ERROR)

# Set project name
project(
  "something_interesting"
  LANGUAGES "Fortran"
  VERSION "0.0.1"
  DESCRIPTION "Doing something"
)

# Define a variable which stores a list of src files
set(SRC_DIR "${PROJECT_SOURCE_DIR}/src")
set(PROJ_SRC_FILES
  "${SRC_DIR}/main.f90"
  "${SRC_DIR}/something.f90"
)

# Build src executables
add_executable("${PROJECT_NAME}" "${PROJ_SRC_FILES}")

# Enable testing.
enable_testing()

# Add the tests dir as a subdirectory.
add_subdirectory("tests")

The tests/CMakeLists.txt file would then look like this:

CMAKE

find_package(PFUNIT REQUIRED)

# Filter out the main.f90 file. We can only have one main() function in our tests
set(PROJ_SRC_FILES_EXEC_MAIN ${PROJ_SRC_FILES})
list(FILTER PROJ_SRC_FILES_EXEC_MAIN EXCLUDE REGEX ".*main.f90")

# Create library for src code
add_library (SUT STATIC ${PROJ_SRC_FILES_EXEC_MAIN})

# List all test files
set(test_srcs
  "${PROJECT_SOURCE_DIR}/tests/test_something.pf"
  "${PROJECT_SOURCE_DIR}/tests/test_something_else.pf"
)

# Add the test target
add_pfunit_ctest (test_something_interesting
  TEST_SOURCES ${test_srcs}
  LINK_LIBRARIES SUT # your application library
  )

Key points:

  • First, we find the pFUnit package to ensure the required libraries and cmake functions are available
  • We then filter the main.f90 program file from the list of src files.
  • We store the src files in a library (SUT, stands for system under test) to be referenced later.
  • We list the test .pf files we wish to include within test_srcs.
  • We then create a test with pFUnit and CTest using the function provided by pFUnit, add_pfunit_ctest. Here we are…
    • naming the test test_something_interesting.
    • informing pFUnit of the relevant src files via TEST_SOURCES.
    • linking to the src library via LINK_LIBRARIES.

Building with CMake

We can then build our tests with the following commands

SH

cmake -B build -DCMAKE_PREFIX_PATH=/path/to/pfunit/build/installed
cmake --build build
ctest --test-dir build # or ./build/tests/test_something_interesting
Callout

Mixing CTest and pFUnit

In this case we have called add_pfunit_ctest once with all of our .pf test files. This results in there being one CTest test (i.e. one executable ./build/tests/test_something) which runs all tests. However, it may be preferable to call add_pfunit_ctest more than once, thus creating multiple executables to further divide up your tests.

Note that the tests can still be filtered by calling the executable itself and using pFUnit’s inbuilt filtering option, like so.

SH

$ ./build/tests/test_something_interesting -f test_something_else -v


 Start: <test_something_else_suite.test_do_something_2>
.   end: <test_something_else_suite.test_do_something_2>

Time:         0.001 seconds

 OK
 (1 test)

Naming our tests with CMake

When we run our tests by directly calling the executable as shown above, we can see that the test suite names and test subroutine names are identical to when built using make. However, when using CMake we have control of one other name. The name of the CTest test. This name is set when we call add_pfunit_ctest. For example the below will create a test named test_something_interesting.

CMAKE

add_pfunit_ctest (test_something_interesting
  TEST_SOURCES ${test_srcs}
  LINK_LIBRARIES sut # your application library
  )
Challenge

Challenge: Practice integrating with CMake

To verify your newly implemented tests of temp_conversions from the previous episode, complete part ii of the building-the-test section of 3-writing-your-first-unit-test/challenge and integrate your test(s) with the CMake build system provided in the exercise.

A solution is provided in 3-writing-your-first-unit-test/solution.

Content from Parameterising pFUnit tests


Last updated on 2026-05-26 | Edit this page

Overview

Questions

  • Why is it useful to parameterise a test?
  • What is the syntax of writing a parameterised unit test in Fortran?

Objectives

  • Parameterise our own pFUnit test.

No that we are able to compile and run the most basic of pFUnit tests, we are ready to look at a more advanced topic, parameterising our tests with pFUnit.

Handling state within tests


If multiple tests rely of the existence of some state such as the allocation of an array. We could repeat this step within each test, like so:

FORTRAN

module test_dot_product_intrinsic
    use funit
    implicit none
contains
    @Test
    subroutine test_dot_product()
        integer, allocatable :: a(:), b(:)
        integer :: c

        ! allocate a and b
        allocate(a(10), b(10))

        ! Define inputs and expected outputs for the scenario we want to test
        a = [1,2,3,4,5,6,7,8,9,10]
        b = [11,12,13,14,15,16,17,18,19,20]
        c = 935

        ! Check that the call to dot_product returned what we expect
        @assertEqual(c, dot_product(a, b), message="Unexpected value returned for the dot_product")

        ! Deallocate to cleanup (not technically necessary)
        deallocate(a, b)

    end subroutine test_dot_product

    @Test
    subroutine test_dot_product_all_zeros()
        integer, allocatable :: a(:), b(:)
        integer :: c

        ! allocate a and b
        allocate(a(10), b(10))

        ! Define inputs and expected outputs for the scenario we want to test
        a = 0
        b = 0
        c = 0

        ! Check that the call to dot_product returned what we expect
        @assertEqual(c, dot_product(a, b), message="Unexpected value returned for the dot_product")

        ! Deallocate to cleanup (not technically necessary)
        deallocate(a, b)

    end subroutine test_dot_product_all_zeros
end module test_dot_product_intrinsic

However, it is generally better to minimise repeated code. Therefore, we can make use of another pFUnit pre-processor directive @TestCase:

FORTRAN

module test_dot_product_intrinsic
    use funit
    implicit none

    !> Custom test case type allowing a single definition of setup and tearDown logic
    @TestCase(constructor=dot_product_test_case_constructor)
    type, extends(TestCase) :: dot_product_test_case
        !> The input array `a` to be passed to dot_product
        integer, allocatable :: a(:)
        !> The input array `b` to be passed to dot_product
        integer, allocatable :: b(:)
    contains
        !> A type-bound procedure which will run after each test which, essentially,
        !> acts like a destructor for this type
        procedure :: tearDown
    end type dot_product_test_case

contains

    !> Constructor for our custom test case type which allocates arrays `a` and `b`
    function dot_product_test_case_constructor() result(newTestCase)
        !> The new instance of our custom test case type to be constructed
        type(dot_product_test_case) :: newTestCase

        allocate(newTestCase%a(10))
        allocate(newTestCase%b(10))
    end function dot_product_test_case_constructor

    !> Essentially a destructor for our custom test case type which deallocates
    !> arrays `a` and `b`
    subroutine tearDown(this)
        !> The instance of our custom test case type which we want to teardown
        class(dot_product_test_case), intent(inout) :: this

        deallocate(this%a)
        deallocate(this%b)
    end subroutine tearDown

    @Test
    subroutine test_dot_product(this)
        !> The instance of our test case type for this test
        class(dot_product_test_case), intent(inout) :: this
        integer :: c

        ! Define inputs and expected outputs for the scenario we want to test
        this%a = [1,2,3,4,5,6,7,8,9,10]
        this%b = [11,12,13,14,15,16,17,18,19,20]
        c = 935

        ! Check that the call to dot_product returned what we expect
        @assertEqual(c, dot_product(this%a, this%b), message="Unexpected value returned for the dot_product")
    end subroutine test_dot_product

    @Test
    subroutine test_dot_product_all_zeros(this)
        !> The instance of our test case type for this test
        class(dot_product_test_case), intent(inout) :: this
        integer :: c

        ! Define inputs and expected outputs for the scenario we want to test
        this%a = 0
        this%b = 0
        c = 0

        ! Check that the call to dot_product returned what we expect
        @assertEqual(c, dot_product(this%a, this%b), message="Unexpected value returned for the dot_product")
    end subroutine test_dot_product_all_zeros
end module test_dot_product_intrinsic

There are a few key things we have done in the above code:

  • Defined our own custom derived type dot_product_test_case which contains the two arrays a and b as type-bound parameters.
  • dot_product_test_case also contains a type-bound procedures tearDown which deallocates a and b.
  • To first allocate a and b we have defined a constructor dot_product_test_case_constructor.
  • These two procedures, tearDown and dot_product_test_case_constructor, allow us to move the previously repeated logic to one location.
  • Finally, to ensure our new custom type is understood and used correctly by pFUnit, we must include two things in it’s definition:
    1. Ensure this type extends one provided by the pFUnit library - TestCase.
    2. Decorate this new type with the pre-processor directive @TestCase, ensuring that we pass dot_product_test_case_constructor as the constructor.

Parameterising tests


By defining a custom test case type, we have begun to reduce repetition within our test. However, there is further repetition to be removed. For example, in both @Test’s we are calling dot_product and running the same assertion. To remove this, we can paramaterise our test. This is done by defining a new custom type dot_product_test_parameters:

FORTRAN

module test_dot_product_intrinsic
    use funit
    implicit none

    !> Custom test parameters type containing all of the inputs and expected
    !! outputs of the intrinsic dot_product
    @TestParameter
    type, extends(AbstractTestParameter) :: dot_product_test_parameters
        !> The input array `a` to be passed to dot_product
        integer, allocatable :: a(:)
        !> The input array `b` to be passed to dot_product
        integer, allocatable :: b(:)
        !> The expected value to be returned from dot_product
        integer :: expected_dot_product
        !> A description of the test to be outputted for logging
        character(len=100) :: description
    contains
        !> The required type-bound procedure for converting an instance
        !> of this type to a string for logging
        procedure :: toString
    end type dot_product_test_parameters

    !> Custom test case type allowing a single definition of tearDown logic.
    !! If teardown is not required, This could also be thought of as boilerplate
    !! required to make the parameters available within our @Test.
    @TestCase(constructor=dot_product_test_case_constructor)
    type, extends(ParameterizedTestCase) :: dot_product_test_case
        !> The instance of our test parameters type to be used within the test logic
        type(dot_product_test_parameters) :: params
    contains
        procedure :: tearDown
    end type dot_product_test_case

contains

    !> Trims and returns the description of the parameter set. The string returned
    !! by this function will be included by pFUnit in the name of this test
    function toString(this) result(string)
        class (dot_product_test_parameters), intent(in) :: this
        character(:), allocatable :: string

        string = trim(this%description)
    end function toString

    !> Boilerplate constructor required to convert our custom parameters type to
    !! the test case type.
    function dot_product_test_case_constructor(testParameters) result(newTestCase)
        type(dot_product_test_parameters), intent(in) :: testParameters
        type(dot_product_test_case) :: newTestCase

        newTestCase%params = testParameters
    end function dot_product_test_case_constructor

    !> Essentially a destructor for our custom test case type which deallocates
    !! arrays `a` and `b`
    subroutine tearDown(this)
        !> The instance of our custom test case type which we want to teardown
        class(dot_product_test_case), intent(inout) :: this

        deallocate(this%params%a)
        deallocate(this%params%b)
    end subroutine tearDown

    !> The test suite in which parameter sets (inputs and expected outputs) for each
    !! test are defined.
    function dot_product_test_suite() result(parameter_sets)
        !> The array of parameter sets to be returned
        type(dot_product_test_parameters) :: parameter_sets(2)

        integer, allocatable :: a(:), b(:)
        integer :: c

        allocate(a(10))
        allocate(b(10))

        ! Parameter set 1
        a = [1,2,3,4,5,6,7,8,9,10]
        b = [11,12,13,14,15,16,17,18,19,20]
        c = 935
        ! Here `dot_product_test_parameters` is a default constructor generated by our
        ! type definition
        parameter_sets(1) = dot_product_test_parameters(a, b, c, "10x10 incrementing values")

        ! Parameter set 2
        a = 0
        b = 0
        c = 0
        parameter_sets(2) = dot_product_test_parameters(a, b, c, "10x10 all zeros")

        ! Deallocate the temporary stores of a and b for completeness
        deallocate(a, b)
    end function dot_product_test_suite


    @Test(testParameters={dot_product_test_suite()})
    subroutine test_dot_product(this)
        !> The instance of our test case type for this test
        class(dot_product_test_case), intent(inout) :: this

        ! Check that the call to dot_product returned what we expect
        @AssertEqual(this%params%expected_dot_product, dot_product(this%params%a, this%params%b), message="Unexpected value returned for the dot_product")
    end subroutine test_dot_product
end module test_dot_product_intrinsic

There is a lot of new aspects being introduced in the above test so let’s break them down.

First of all, we have defined a new custom type dot_product_test_parameters

FORTRAN

!> Custom test parameters type containing all of the inputs and expected
!> outputs of the intrinsic dot_product
@TestParameter
type, extends(AbstractTestParameter) :: dot_product_test_parameters
    !> The input array `a` to be passed to dot_product
    integer, allocatable :: a(:)
    !> The input array `b` to be passed to dot_product
    integer, allocatable :: b(:)
    !> The expected value to be returned from dot_product
    integer :: expected_dot_product
    !> A description of the test to be outputted for logging
    character(len=100) :: description
contains
    !> The required type-bound procedure for converting an instance
    !> of this type to a string for logging
    procedure :: toString
end type dot_product_test_parameters

The key features of this the type dot_product_test_parameters are

  • It is decorated with the directive @TestParameter to inform the pre-processor that this is a test parameter type.
  • It extends the type AbstractTestParameter provided by the pFUnit library to allow the pfunit test runner to utlisise this custom type.
  • All inputs (a and b) and expected outputs (expected_dot_product) of dot_product are define as type-bound variables.
  • The type-bound variable description and procedure toString allow conversion of a single test parameter instance to a character array for logging (see below).

toString

pFUnit requires that a type which extends AbstractTestParameter must define a type-bound procedure called toString:

FORTRAN

!> Trims and returns the description of the parameter set. The string returned
!> by this function will be included by pFUnit in the name of this test
function toString(this) result(string)
    class (dot_product_test_parameters), intent(in) :: this
    character(:), allocatable :: string

    string = trim(this%description)
end function toString

For simplicity, we utilise the variable description to define this string in its entirety.

Callout

Default type constructor

All derived types in Fortran are given a default constructor of the same name which can be invoked like a function. For example, we can create an instance of our type dot_product_test_parameters like so:

FORTRAN

type(dot_product_test_parameters) :: testParameters

testParameters = dot_product_test_parameters(a, b, expected_dot_product, "10x10 incrementing values")
Challenge

Challenge: Parameterising temperature conversion tests with pFUnit, part 1

Begin parameterising your pFUnit tests of temperature conversions by creating a custom derived type for your test parameters.

See part 2 of 3-writing-your-first-unit-test/challenge.

A solution is provided in 3-writing-your-first-unit-test/solution.

Now that we have a new test parameter type, we must update our test case type to make use of it:

FORTRAN

!> Custom test case type allowing a single definition of tearDown logic.
!! If teardown is not required, This could also be thought of as boilerplate
!! required to make the parameters available within our @Test.
@TestCase(constructor=dot_product_test_case_constructor)
type, extends(ParameterizedTestCase) :: dot_product_test_case
    !> The instance of our test parameters type to be used within the test logic
    type(dot_product_test_parameters) :: params
contains
    procedure :: tearDown
end type dot_product_test_case

The key points to highlight are:

  • We are now extending the base type ParameterizedTestCase to inform the pre-processor that this is a test case that should be parameterised.
  • To prevent duplication we simply define an instance of our test parameter type as a type-bound variable.
  • The type-bound procedure teardown remains the same.

Test case constructor

Whilst teardown remains almost unchanged, dot_product_test_case_constructor has changed considerably. We now no longer use this as a mechanism to setup state but instead we are converting an instance of our parameter type (dot_product_test_parameters) into an instance of our test case type (dot_product_test_case).

F90

!> Boilerplate constructor required to convert our custom parameters type to
!! the test case type.
function dot_product_test_case_constructor(testParameters) result(newTestCase)
    type(dot_product_test_parameters), intent(in) :: testParameters
    type(dot_product_test_case) :: newTestCase

    newTestCase%params = testParameters
end function dot_product_test_case_constructor
Callout

Setting up state

Now that we are not using the test case constructor for setting up state we need a new place for this to be done. pFUnit allows us to do this in similar way to teardown by adding a new type-bound procedure within our test case type called setUp.

Challenge

Challenge: Parameterising temperature conversion tests with pFUnit, part 2

Continue parameterising your pFUnit tests of temperature conversions by Updating your custom test case type to utilise your new parameter type.

See part 2 of 3-writing-your-first-unit-test/challenge.

A solution is provided in 3-writing-your-first-unit-test/solution.

We now are able to parameterise our test case. To do this we define a function (dot_product_test_suite) which returns an array of our custom test parameter type - dot_product_test_parameters - which we call the test suite.

F90

!> The test suite in which parameter sets (inputs and expected outputs) for each
!! test are defined.
function dot_product_test_suite() result(parameter_sets)
    !> The array of parameter sets to be returned
    type(dot_product_test_parameters) :: parameter_sets(2)

    integer, allocatable :: a(:), b(:)
    integer :: c

    allocate(a(10))
    allocate(b(10))

    ! Parameter set 1
    a = [1,2,3,4,5,6,7,8,9,10]
    b = [11,12,13,14,15,16,17,18,19,20]
    c = 935
    ! Here `dot_product_test_parameters` is a default constructor generated by our
    ! type definition
    parameter_sets(1) = dot_product_test_parameters(a, b, c, "10x10 incrementing values")

    ! Parameter set 2
    a = 0
    b = 0
    c = 0
    parameter_sets(2) = dot_product_test_parameters(a, b, c, "10x10 all zeros")

    ! Deallocate the temporary stores of a and b for completeness
    deallocate(a, b)
end function dot_product_test_suite

Let’s look at the key aspects of this function:

  • We return an array of dot_product_test_parameters where each element is a single test case.
  • This is now where we allocate a and b.
Challenge

Challenge: Parameterising temperature conversion tests with pFUnit, part 3

Continue parameterising your pFUnit tests of temperature conversions by defining your parameters sets and returning them from your test suite function.

See part 2 of 3-writing-your-first-unit-test/challenge.

A solution is provided in 3-writing-your-first-unit-test/solution.

Since we are now defining our inputs and expected outputs within our custom type dot_product_test_parameters and setting their values in dot_product_test_suite, we can simplify the contents of our test subroutine:

F90

@Test(testParameters={dot_product_test_suite()})
subroutine test_dot_product(this)
    !> The instance of our test case type for this test
    class(dot_product_test_case), intent(inout) :: this

    ! Check that the call to dot_product returned what we expect
    @AssertEqual(this%params%expected_dot_product, dot_product(this%params%a, this%params%b), message="Unexpected value returned for the dot_product")
end subroutine test_dot_product

The key aspects are:

  • The @Test directive now takes a testParameters value which we return from our test suite dot_product_test_suite.
  • We no longer set any inputs or expected outputs within this test subroutine but simply just call dot_product and @AssertEqual.
Challenge

Challenge: Parameterising temperature conversion tests with pFUnit, part 4

Finish parameterising your pFUnit tests of temperature conversions by Updating your test subroutine to make use of your new custom parameter type and test suite.

See part 2 of 3-writing-your-first-unit-test/challenge.

A solution is provided in 3-writing-your-first-unit-test/solution.

Content from Testing parallel code


Last updated on 2026-05-26 | Edit this page

Overview

Questions

  • How do I unit test a procedure which makes MPI calls?
  • How do I easily test different numbers of MPI ranks?

Objectives

  • Understand what is different when testing parallel vs serial code.

What’s the difference?


Depending on the parallelisation tool and strategy employed, the implementation of parallel code can be different to that of serial code. This is especially true for code which utilises the message passing interface (MPI). These codes almost always contain some functionality in which processes, or ranks, communicate by exchanging messages. This message passing is often complex and will always benefit from testing.

There is added complexity when testing MPI code compared to serial as the logical path through the code is changed depending on the number of ranks with which the code is executed. Therefore, it is important that we test for a range of numbers of ranks. This will require controlling the number of ranks running the src and is not something we want to implement ourselves. pFUnit can handle this for us.

Tips for writing testable MPI code


Where possible, separate calls to the MPI library into procedures

If a procedure does not contain any calls to the MPI library, then it can be tested with a serial unit test. Therefore, separating MPI calls into their own units makes for a simpler test suite for most of your logic. Only, procedures with MPI library calls will require MPI enabled pFUnit tests.

Pass the MPI communicator information into each mpi procedure to be tested

If we pass the MPI communicator into a procedure, we can define this to be whatever we wish in our tests. This allows us to use the communicator provided by pFUnit or some other communicator specific to our problem.

Creating types to wrap this information along with any other MPI specific information (neighbour ranks, etc) can be a convenient approach.

Syntax of writing MPI enabled pFUnit tests


To test MPI code, we must inform pFUnit that we intend to do so. Firstly, we must change how we define our test parameters. Assuming our src procedure returns the same value to all ranks for any number of MPI ranks, we can do the following:

  • We now use MPITestParameter instead of AbstractTestParameter.
    • MPITestParameter inherits from AbstractTestParameter and provides an additional parameter in its constructor which corresponds to the number of processors for which a particular test should be ran.

F90

@testParameter
type, extends(MPITestParameter) :: my_test_params
    integer :: input
    integer :: expected_output
contains
    procedure :: toString => my_test_params_toString
end type my_test_params

We also need to change how we define our test case:

  • We now use MPITestCase instead of ParameterizedTestCase
    • MPITestCase provides several helpful methods for us to use whilst testing
      • getProcessRank() returns the rank of the current process allowing per rank selection of inputs and expected outputs.
      • getMpiCommunicator() returns the MPI communicator created by pFUnit to control the number of ranks per test.
      • getNumProcesses() returns the number of MPI ranks for the current test.

F90

@TestCase(constructor=my_test_params_to_my_test_case, testParameters={my_test_suite()})
type, extends(MPITestCase) :: my_test_case
    type(my_test_params) :: params
end type my_test_case
Challenge

Challenge: Update derived types to work with MPI

Take a look at the exercise 6-testing-parallel-code. This exercise contains an MPI parallelised version of the game of life from episode 2. Refactoring Fortran and the exercise 4-fortran-unit-test-syntax.

Complete the first step of the challenge by converting the derived types within test_find_steady_state.pf to work with MPI.

Your derived types should now look something like this,

F90

@testParameter
type, extends(MPITestParameter) :: find_steady_state_test_params
    !> The initial starting board to be passed into find_steady_state
    integer, dimension(:,:), allocatable :: input_board
    !> The expected steady state result
    logical :: expected_steady_state
    !> The expected number of generations to reach steady state
    integer :: expected_generation_number
    !> A description of the test to be outputted for logging
    character(len=100) :: description
contains
    procedure :: toString => find_steady_state_test_params_toString
end type find_steady_state_test_params

!> Type to define a single find_steady_state test case
@TestCase(testParameters={getTestSuite()}, constructor=paramsToCase)
type, extends(MPITestCase) :: find_steady_state_test_case
    type(find_steady_state_test_params) :: params
end type find_steady_state_test_case

Now that we have updated our derived types, we must update how we populate our test parameter sets within the test suite. There is actually little that needs to change, all we must do is set how many MPI ranks we want each parameter set to be run with. For example,

F90

function my_test_suite() result(params)
    type(my_test_params), allocatable :: params(:)

    integer :: i, max_num_ranks

    # Run two tests for each number of MPI ranks
    max_num_ranks = 8
    allocate(params(max_num_ranks * 2))
    do i = 1, max_num_ranks
        params(i)     = my_test_params(i, 1, 2)  ! Given input is 1, output is 2
        params(i + 1) = my_test_params(i, 3, 4)  ! Given input is 3, output is 4
    end do
end function my_test_suite
Challenge

Challenge: Update test suite to work with MPI

Continuing with the exercise 6-testing-parallel-code.

Complete the next step of the challenge by converting the test suite within test_find_steady_state.pf to work with your new derived types.

Your derived types should now look something like this,

F90

function getTestSuite() result(params)
    !> The array of test parameters
    type(find_steady_state_test_params), allocatable :: params(:)

    integer :: i, max_num_ranks
    integer, dimension(:,:), allocatable :: board

    !  Steady state should be reached after 17 iterations
    !       8  9 10 11 12
    !      -- -- -- -- --
    !   8 | 0  0  0  0  0
    !   9 | 0  0  1  0  0
    !  10 | 0  1  1  1  0
    !  11 | 0  1  0  1  0
    !  12 | 0  0  1  0  0
    !  13 | 0  0  0  0  0
    allocate(board(31, 31))
    board = 0
    board(9,9:11)  = [0,1,0]
    board(10,9:11) = [1,1,1]
    board(11,9:11) = [1,0,1]
    board(12,9:11) = [0,1,0]

    max_num_ranks = 8
    allocate(params(max_num_ranks))
    do i = 1, max_num_ranks
        params(i) = find_steady_state_test_params(i, board, .true., 17, "an exploder initial state")
    end do
end function getTestSuite

As we are assuming our src procedure returns the same value to all ranks for any number of MPI ranks there is not much that needs to change within our test logic subroutine. The one thing that is likely to change in this case is the call to the src procedure being tested as it is recommended to pass the MPI communicator into each procedure which utilises MPI. For example, the test logic might look something like this.

F90

@Test
subroutine TestMySrcProcedure(this)
    class (my_test_case), intent(inout) :: this

    integer :: actual_output

    call my_src_procedure(this%params%input, actual_output, this%getMpiCommunicator(), this%getNumProcessesRequested())

    @assertEqual(this%params%expected_output, actual_output, "Unexpected output from my_src_procedure")
end subroutine TestMySrcProcedure
Callout

In the example above, the MPI communicator is passed into the src procedure. Using the function provided by pFUnit this%getMpiCommunicator() allows pFUnit to manage the number of ranks used within each test.

Challenge

Challenge: Update test logic to work with MPI

Continuing with the exercise 6-testing-parallel-code.

Converting the test logic within test_find_steady_state.pf to work with the new src procedure signature.

Your derived types should now look something like this,

F90

@Test
subroutine TestFindSteadyState(this)
    !> The current test case including inputs and expected outputs
    class(find_steady_state_test_case), intent(inout) :: this

    logical :: actual_steady_state
    integer :: actual_generation_number

    call find_steady_state(actual_steady_state, actual_generation_number, this%params%input_board, &
        size(this%params%input_board, 1), size(this%params%input_board, 2), this%getMpiCommunicator(), &
        this%getNumProcessesRequested())

    @assertEqual(this%params%expected_generation_number, actual_generation_number, "Unexpected generation_number")

    @assertTrue(this%params%expected_steady_state .eqv. actual_steady_state, "Unexpected steady_state value")

end subroutine TestFindSteadyState

Converting to supporting MPI has not altered the relationship between the test parameters and the test case. Therefore, the constructors will remain unchanged.

Integrating with build systems


Just like serial tests, MPI tests can be integrated into projects which utilise either Make or CMake.

Integrating with Make

To build MPI enabled pFUnit tests via Make, one must use an mpi enabled compiler such as mpif90 and include the pFUnit library in the compiler arguments -lpfunit. Therefore, the tests/Makefile from 5-integrating-with-build-systems#integrating-pfunit-with-make becomes,

MAKEFILE

PFUNIT_INCLUDE_DIR ?= /path/to/pfunit/include

# Don't try to include if we're cleaning as this doesn't depend on pFUnit
ifneq ($(MAKECMDGOALS),clean)
include $(PFUNIT_INCLUDE_DIR)/PFUNIT.mk
TEST_FLAGS = -I$(BUILD_DIR) $(FC_FLAGS) $(LIBS) $(PFUNIT_EXTRA_FFLAGS) -lpfunit # <-- Lib added here
endif

# Define variables to be picked up by make_pfunit_test
tests_TESTS = \
  test_something.pf \
  test_something_else.pf
tests_OTHER_SOURCES = $(filter-out $(BUILD_DIR)/main.o, $(SRC_OBJS))
tests_OTHER_LIBRARIES = $(TEST_FLAGS)

# Triggers pre-processing and defines rule for building test executable
$(eval $(call make_pfunit_test,tests))

# Converts pre-processed test files into objects ready for building of the executable
%.o: %.F90
 $(FC) -c $(TEST_FLAGS) $<

clean:
 \rm -f *.o *.mod *.F90 *.inc tests

With this, we can compile for MPI using the following command.

SH

PFUNIT_INCLUDE_DIR=/path/to/pfunit/include FC=mpif90 make tests

Integrating with CMake

The difference between a serial test and an MPI test built using CMake is minimal. For an MPI test add_pfunit_ctest will produce an executable which must be run with an appropriate MPI runner (i.e. mpirun or mpiexec). To achieve this, there is only one extra parameter we must pass into add_pfunit_ctest as shown below.

CMAKE

add_pfunit_ctest (test_something_interesting
  TEST_SOURCES ${test_srcs}
  LINK_LIBRARIES SUT # your application library
  MAX_PES 4
  )

MAX_PES informs pFUnit of the maximum number of MPI ranks with which the tests within test_srcs should be run. Therefore, this number should match the largest number of ranks requested in the tests defined within test_srcs (i.e. the largest value passed as the first argument into a MPITestParameter constructor).

Testing more complex procedures


Thus far we have been assuming our src procedure returns the same value to all ranks for any number of MPI ranks. We must do things slightly differently if we expect different values to be returned for different ranks. To handle this scenario we can make use of the functions provided by pFUnit, getNumProcesses() and getProcessRank(). However, these values are not set until the test case runs (i.e. until we are within the subroutine decorated with @Test). Therefore, we must be a little clever about how we populate our test parameters.

We can build arrays of input parameters with the rank of a process matching the index of the parameter array. For example, rank 0 would access index 1 of the input array during testing, rank 1 would access index 2 and so on. For example, if we define our test parameter type to use arrays, like so,

F90

@testParameter
type, extends(MPITestParameter) :: my_test_params
    integer, allocatable :: input(:)
    integer, allocatable :: expected_output(:)
contains
    procedure :: toString => my_test_params_toString
end type my_test_params

We can then update how we populate our test parameters to take into account the rank indexing:

F90

function my_test_suite() result(params)
    type(my_test_params), allocatable :: params(:)
    integer, allocatable :: input(:)
    integer, allocatable :: expected_output(:)
    integer, max_number_of_ranks

    max_number_of_ranks = 2
    allocate(params(max_number_of_ranks))
    allocate(input(max_number_of_ranks))
    allocate(expected_output(max_number_of_ranks))

    ! Tests with one rank
    input(1) = 1
    expected_output(1) = 2
    params(1) = my_test_params(1, input, expected_output)

    ! Tests with two ranks
    !     rank 0
    input(1) = 1
    expected_output(1) = 1
    !     rank 1
    input(2) = 1
    expected_output(2) = 1
    params(2) = my_test_params(2, input, expected_output)
end function my_test_suite

Finally, we need to ensure each process accesses the correct rank indexed parameters during the test

F90

@Test
subroutine TestMySrcProcedure(this)
    class (my_test_case), intent(inout) :: this

    integer :: actual_output, rank_index

    rank_index = this%getProcessRank() + 1

    call my_src_procedure(this%params%input(rank_index), actual_output)

    @assertEqual(this%params%expected_output(rank_index), actual_output, "Unexpected output from my_src_procedure")
end subroutine TestMySrcProcedure
Challenge

Challenge: A more complex MPI test

Take a look at part 3 of 6-testing-parallel-code/challenge in the exercises repository.

A solution is provided in 6-testing-parallel-code/solution.