Test case fixtures

Test case fixtures#

You will learn how to

  • create and use test case fixtures in Fortuno.

Note

This section assumes that you already have a working Fortuno project with unit tests in place. If you haven’t set one up yet, we recommend using the Fortran project cookiecutter template or following the step-by-step guidance in the Tutorials section.

Fixtures in unit testing are essential for preparing and cleaning up the environment around each test case. They help ensure that each test starts with a known, valid setup and that any resources used during the test are properly released afterward. Common tasks handled by fixtures include opening and closing files, managing database connections, or precomputing values required by tests.

Fortuno provides flexible mechanisms for implementing such fixtures. In this section, we will explore two main approaches: a manual fixture and an automatic fixture. We’ll start with a basic example and then move toward the more sophisticated setup.

Throughout the examples, we’ll simulate a temporary file environment. The setup phase will involve creating and opening a temporary file, while the teardown will consist of closing and cleaning up the file. Each test will receive both the file name and its corresponding unit number as input parameters. To generate random file names, we use the following simple helper function:

random_file.f90#
module random_file
  implicit none

  private
  public :: random_file_name

contains


  !> Returns a random temporary file name with fixed prefix and suffix
  function random_file_name(prefix, suffix, seqlen) result(tempfile)

    !> Prefix to use in the file name
    character(*), intent(in) :: prefix

    !> Suffix to use in the file name
    character(*), intent(in) :: suffix

    !> Lenght of the random sequence in the file name
    integer, intent(in) :: seqlen

    !> Generated file name on exit
    character(len=:), allocatable :: tempfile

    character(*), parameter :: charset =  "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ&
        &abcdefghijklmnopqrstuvwxyz"

    real :: rand
    integer :: ind, ii
    
    call random_seed()
    allocate(character(len=seqlen + len(prefix) + len(suffix)) :: tempfile)
    tempfile(: len(prefix)) = prefix
    do ii = 1, seqlen
      call random_number(rand)
      ind = 1 + int(rand * real(len(charset)))
      tempfile(len(prefix) + ii : len(prefix) + ii) = charset(ind : ind)
    end do
    tempfile(len(prefix) + seqlen + 1 :) = suffix
  
  end function random_file_name

end module random_file

Make sure to include this module when compiling the examples that follow.

Manual Fixture#

The most straightforward strategy is to handle setup and teardown manually within each test case. Although this method does not rely on Fortuno-specific features, it is often effective for smaller or less complex test setups.

In this approach, we define a custom type that holds all necessary environment data (e.g., the temporary file name and unit number). Each test explicitly creates an instance of this type and manually invokes an initialization routine at the beginning. Cleanup is handled through a finalizer associated with the type, which ensures proper teardown once the test completes.

test/testapp.f90#
!> Module containing unit tests
module test_mylib
  use random_file, only : random_file_name
  use fortuno_serial, only : is_equal, test => serial_case_item, check => serial_check,&
      & check_failed => serial_check_failed, suite => serial_suite_item, test_list
  implicit none

  type :: test_env
    character(:), allocatable :: filename
    integer :: unit = -1
  contains
    final :: final_test_env
  end type test_env

contains

  !> Returns the list of tests in this module
  function tests()
    type(test_list) :: tests

    tests = test_list([&
        suite("tempfile_demo", test_list([&
            test("tempfile_1", test_tempfile_1),&
            test("tempfile_2", test_tempfile_2)&
        ]))&
    ])

  end function tests


  !> Intializes the test environment (opens temporary file)
  subroutine init_test_env(this)

    !> Test environment containing file name and file unit.
    !!
    !! Note: if the opening of the temporary file fails for any reasons the unit remains at its
    !! default value (-1) and the file name string will be unallocated.
    type(test_env), intent(out) :: this

    integer :: iostat

    this%filename = random_file_name("tmp-", ".txt", 10)
    open(newunit=this%unit, file=this%filename, action="readwrite", iostat=iostat)
    if (iostat /= 0) this = test_env()

  end subroutine init_test_env


  !> Finalizes the test environment (closes temporary file)
  subroutine final_test_env(this)
    type(test_env), intent(inout) :: this
    
    if (this%unit /= -1) then
      close(this%unit, status="delete")
    end if

  end subroutine final_test_env
  

  subroutine test_tempfile_1()
    type(test_env) :: env
    call init_test_env(env)
    call check(env%unit /= -1, msg="Failed to open tempfile")
    if (check_failed()) return
    write(env%unit, "(a)") "Hello from test_tempfile_1"
  end subroutine test_tempfile_1

  
  subroutine test_tempfile_2()
    type(test_env) :: env
    call init_test_env(env)
    call check(env%unit /= -1, msg="Failed to open tempfile")
    if (check_failed()) return
    write(env%unit, "(a)") "Hello from test_tempfile_2"
  end subroutine test_tempfile_2

end module test_mylib


!> Test app driving Fortuno unit tests.
program testapp
  use test_mylib, only : tests
  use fortuno_serial, only : execute_serial_cmd_app
  implicit none

  call execute_serial_cmd_app(tests())

end program testapp

This method has the benefit of being highly transparent. Each test contains its own explicit setup logic, which makes the test’s behavior easy to follow and debug. There is no hidden initialization logic happening “behind the scenes.” However, this approach leads to duplicated code across tests. Forgetting to call the initialization routine, or calling it incorrectly, may lead to subtle and hard-to-trace errors.

To address these issues, Fortuno allows you to externalize setup and teardown logic using automatic fixtures, described next.

Automatic Fixture#

An automatic fixture improves maintainability by handling setup and teardown outside the test itself. It also prevents accidental modification of test parameters during execution by controlling how they are accessed.

To implement an automatic fixture, we modify the standard testing pattern in two key ways:

  • Test routines are written with an intent(in) argument that provides read-only access to the prepared test environment.

  • Initialization and finalization of this environment are performed outside the test routine, ensuring consistency and reducing boilerplate code.

Thanks to Fortuno’s object-oriented architecture, integrating these changes is straightforward. Let’s walk through the required steps:

  • In Fortuno’s default setup, the serial_case type is used to represent test cases. This type extends a base type called serial_case_base and adds a pointer to a no-argument test procedure. Additionally, the run() method of the base type is overridden to invoke this argumentless procedure when the test is executed.

  • For our custom fixture, we define a new type that also extends serial_case_base. Instead of pointing to a procedure without arguments, it will store a pointer to a test routine with one dummy argument (our test environment). We override the run() method in this new type to set up the environment, to call the appropriate test routine and to tear down the environment again.

  • Fortuno organizes test items—such as test cases, test suites, and their user-defined extensions—using arrays. Since Fortran arrays must be homogeneous (i.e., all elements must be of the same declared type), each test item is wrapped using the test_item type to ensure type uniformity. The test_item type holds a generic class pointer to the shared base type of all test cases and suites, allowing it to encapsulate any valid test item. To streamline the wrapping process, we will provide a helper function that constructs and wraps instances of our custom test case types automatically.

The following example demonstrates a minimal but complete implementation. Key changes are highlighted for clarity:

test/testapp.f90#
  1!> Module containing unit tests
  2module test_mylib
  3  use random_file, only : random_file_name
  4  use fortuno_serial, only : serial_case_base, check => serial_check,&
  5      & check_failed => serial_check_failed, suite => serial_suite_item, test_item, test_list
  6  implicit none
  7
  8  type :: test_env
  9    character(:), allocatable :: filename
 10    integer :: unit = -1
 11  contains
 12    final :: final_test_env
 13  end type test_env
 14
 15
 16  !> Fixtured test case
 17  type, extends(serial_case_base) :: tempfile_case
 18    procedure(test_tempfile_1), pointer, nopass :: proc
 19  contains
 20    procedure :: run => tempfile_case_run
 21  end type tempfile_case
 22
 23contains
 24
 25  !> Returns the list of tests in this module
 26  function tests()
 27    type(test_list) :: tests
 28
 29    tests = test_list([&
 30        suite("tempfile_demo", test_list([&
 31            tempfile_test("tempfile_1", test_tempfile_1),&
 32            tempfile_test("tempfile_2", test_tempfile_2)&
 33        ]))&
 34    ])
 35
 36  end function tests
 37
 38
 39  !> Intializes the test environment (opens temporary file)
 40  subroutine init_test_env(this)
 41
 42    !> Test environment containing file name and file unit.
 43    !!
 44    !! Note: if the opening of the temporary file fails for any reasons the unit remains at its
 45    !! default value (-1) and the file name string will be unallocated.
 46    type(test_env), intent(out) :: this
 47
 48    integer :: iostat
 49
 50    this%filename = random_file_name("tmp-", ".txt", 10)
 51    open(newunit=this%unit, file=this%filename, action="readwrite", iostat=iostat)
 52    if (iostat /= 0) this = test_env()
 53
 54  end subroutine init_test_env
 55
 56
 57  !> Finalizes the test environment (closes temporary file)
 58  subroutine final_test_env(this)
 59    type(test_env), intent(inout) :: this
 60    
 61    if (this%unit /= -1) then
 62      close(this%unit, status="delete")
 63    end if
 64
 65  end subroutine final_test_env
 66
 67
 68  !> Wraps a tempfile_case instance as test_item suitable for array constructors.
 69  function tempfile_test(name, proc) result(testitem)
 70    character(*), intent(in) :: name
 71    procedure(test_tempfile_1) :: proc
 72    type(test_item) :: testitem
 73
 74    testitem = test_item(tempfile_case(name=name, proc=proc))
 75
 76  end function tempfile_test
 77
 78
 79  !> Run procedure of the tempfile_case type.
 80  subroutine tempfile_case_run(this)
 81    class(tempfile_case), intent(in) :: this
 82
 83    type(test_env) :: env
 84    
 85    call init_test_env(env)
 86    call check(env%unit /= -1, msg="Failed to open tempfile")
 87    if (check_failed()) return
 88    call this%proc(env)
 89
 90  end subroutine tempfile_case_run
 91  
 92
 93  subroutine test_tempfile_1(env)
 94    type(test_env), intent(in) :: env
 95    write(env%unit, "(a)") "Hello from test_tempfile_1"
 96  end subroutine test_tempfile_1
 97
 98  
 99  subroutine test_tempfile_2(env)
100    type(test_env), intent(in) :: env
101    write(env%unit, "(a)") "Hello from test_tempfile_2"
102  end subroutine test_tempfile_2
103
104end module test_mylib
105
106
107!> Test app driving Fortuno unit tests.
108program testapp
109  use test_mylib, only : tests
110  use fortuno_serial, only : execute_serial_cmd_app
111  implicit none
112
113  call execute_serial_cmd_app(tests())
114
115end program testapp

Here are a few additional remarks on the implementation:

  • Line 16–21: When defining the custom test case type tempfile_case, we reference the signature of an existing test procedure. This approach avoids the need for declaring a separate abstract interface, making the code more concise.

  • Line 31–32: We call our custom wrapper function to create a properly wrapped test_item for each test within the array constructor.

  • Lines 68–76: The trivial wrapper function constructs an instance of tempfile_case, and then wraps it in a test_item. The name field is inherited from the base type, while the proc field is defined in our derived type. Note: For future compatibility, always use keyword arguments when calling structure constructors for extended types in Fortuno.

  • Lines 79–90: The run() method first sets up the test environment, checks that the setup was successful, and then calls the test routine. Teardown happens automatically through the finalizer defined for the environment type, which is triggered when the routine exits.

This structure offers a clean and robust way to manage test setup and cleanup in Fortuno, reducing repetition and minimizing the risk of accidental misuse. Automatic fixtures become especially helpful as your test suite grows in size and complexity.