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:
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.
!> 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_casetype is used to represent test cases. This type extends a base type calledserial_case_baseand adds a pointer to a no-argument test procedure. Additionally, therun()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 therun()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_itemtype to ensure type uniformity. Thetest_itemtype 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:
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_itemfor each test within the array constructor.Lines 68–76: The trivial wrapper function constructs an instance of
tempfile_case, and then wraps it in atest_item. Thenamefield is inherited from the base type, while theprocfield 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.