pFUnit icon indicating copy to clipboard operation
pFUnit copied to clipboard

Unclear how to do simple parameterization

Open kurtsansom opened this issue 4 years ago • 6 comments

I am attempting to convert a test to pFUnit.

I have something like this

  call test_set_quadrature_exponential(1.0_dp, tolerance)
  call test_set_quadrature_exponential(2.0_dp, tolerance)

where test_set_quadrature_exponential has the asserts.

this approach I don't think works with pFUnit because the subroutine test_set_quadrature_exponential wouldn't have the @test keyboard and therefore will not get parsed?

I was trying to follow the parameterized test cases but it looks like a lot more mental load to wrap my head around. it appears to use an object oriented approach.

My interpretation is that one has to write a class that extends the ParameterizedTestCase, and is decorated with @TestCase and the additional testParameters, and a constructor? (is this where the magic happens?)

and also write a class that extends the AbstractTestParameter decorated with @testParameter that specify the parameters (what defines the parameterized variables) that can get stuffed into an array that is passed to the @TestCase decorator attribute testParameters

@TestCase decorator attribute Constructor creates a new instance for each parameter set in the parameters array?

The actual test under the @test decorator creates an instance of the extended ParameterizedTestCase class and then calls the SUT functionality?

Where is the magic that is iterating through the parameterized values? Is there a simpler example out there for each of these features?

or is this the best way to do it?

kurtsansom avatar Dec 09 '20 04:12 kurtsansom

The @test and other annotations are only for user convenience. (Often a very big convenience.) But you may also just want to wrap your test within a pFunit test:

@test
subroutine wrap_test()
   ...
  call test_set_quadrature_exponential(1.0_dp, tolerance)
  call test_set_quadrature_exponential(2.0_dp, tolerance)
end subroutine wrap_test()

(Or maybe put each such call inside its own wrapper.) You can then make calls to assertEqual() within your tests that will be caught and reported by the framework. Lots of little caveats of course. If you want file and line number you'll need to explicitly put that on your call to assertEqual. Likewise, if you want the test to end you'll need to add a call to see if any exceptions have been put on the stack at each level so that you can return early. Happy to have a more detailed discussion if this is the path you want to take.

Or ... ParameterizedTestCase: ParameterizedTestCase is something you only want to take on if you have several tests that all need to loop over a consistent set of parameters. And even then duplication might be easier to implement (but probably harder to maintain.) Looping a parallel test over a varying number of processes is the most common example, and pFUnit provides specialized support for that case to keep it relatively simple. (MpiTestMethod)

It's been a while since I have implemented a ParameterizedTest case, so I had to go back and review the code to recall how it all ties together. In the end, each parameterized test is given just one parameter. Something at a higher level must then construct a different test object for each desired parameter. The easiest way to see this is to look at the output from an existing use case after the pfunit preprocessor has filled in the boiler plate. The end of this process is a module that wraps the user-provided module to provide a custom constructor and then an external function that constructs a test suite. I can provide the full before and after if you are still interested. But the bits you would need to implement would look something like below:


module WrapTest_LatLon_GridFactory
   use FUnit
   use Test_LatLon_GridFactory
   implicit none
   private

   public :: WrapUserTestCase
   public :: makeCustomTest
   type, extends(Test_LatLonGridFactory) :: WrapUserTestCase
      procedure(userTestMethod), nopass, pointer :: testMethodPtr
   contains
      procedure :: runMethod
   end type WrapUserTestCase

   abstract interface
     subroutine userTestMethod(this)
        use Test_LatLon_GridFactory
        class (Test_LatLonGridFactory), intent(inout) :: this
     end subroutine userTestMethod
   end interface

contains

   subroutine runMethod(this)
      class (WrapUserTestCase), intent(inout) :: this

      call this%testMethodPtr(this)
   end subroutine runMethod

   function makeCustomTest(methodName, testMethod, testParameter) result(aTest)
      type (WrapUserTestCase) :: aTest
      character(len=*), intent(in) :: methodName
      procedure(userTestMethod) :: testMethod
      type (GridCase), intent(in) :: testParameter
      aTest%Test_LatLonGridFactory = Test_LatLonGridFactory(testParameter)

      aTest%testMethodPtr => testMethod
#ifdef INTEL_13
      p => aTest
      call p%setName(methodName)
#else
      call aTest%setName(methodName)
#endif
      call aTest%setTestParameter(testParameter)
   end function makeCustomTest

end module WrapTest_LatLon_GridFactory

function Test_LatLon_GridFactory_suite() result(suite)
   use FUnit
   use Test_LatLon_GridFactory
   use WrapTest_LatLon_GridFactory
   implicit none
   type (TestSuite) :: suite

   class (Test), allocatable :: t

   type (GridCase), allocatable :: testParameters(:)
   type (GridCase) :: testParameter
   integer :: iParam 
   integer, allocatable :: cases(:) 
 
   suite = TestSuite('Test_LatLon_GridFactory_suite')

   testParameters = getParameters()

   do iParam = 1, size(testParameters)
      testParameter = testParameters(iParam)
   call suite%addTest(makeCustomTest('test_shape', test_shape, testParameter))
   end do

   testParameters = getParameters()

   do iParam = 1, size(testParameters)
      testParameter = testParameters(iParam)
   call suite%addTest(makeCustomTest('test_centers', test_centers, testParameter))
   end do


end function Test_LatLon_GridFactory_suite

tclune avatar Dec 09 '20 14:12 tclune

thank you @tclune, that is exactly the information I think I needed.

For now I am pushing toward the first suggestion, and as you mentioned I need to add information about the line number manually? In the example below call test_set_quadrature_exponential(0.1_dp, 1.0e-16_dp) fails. The error lists the line number of the failing assert but doesn't track which call to the function it failed on, so this is where I would need to add something of a stacktrace and line number information? is that syntax that the @test decorator can handle or is it manual?

I agree with what your are saying about the parameterized test, I was leaning towards duplication since it only covers two sets of parameters. ParameterizedTestCase looks great! for large sweeps, but a potentially massive hammer unless I know there will be more parameter sets added. I will very likely get to a point that I will implement some test cases that use this feature, but not yet.

Test code:

  @test
  subroutine wrap_test_set_quadrature_exponential()
    real(dp) :: tolerance
    ! Exponential tests
    tolerance = 1.0e-5_dp
    call test_set_quadrature_exponential(1.0_dp, tolerance)
    call test_set_quadrature_exponential(2.0_dp, tolerance)
    ! added the following to get a failure example
    call test_set_quadrature_exponential(0.1_dp, 1.0e-16_dp)
  end subroutine wrap_test_set_quadrature_exponential

  subroutine test_set_quadrature_exponential(alpha, tol)
    use mathlib, only: set_gauss_laguerre_quadrature
    real(dp), intent(in) :: alpha
    real(dp), intent(in) :: tol
    real(dp), dimension(32) :: points
    real(dp), dimension(32) :: weights
    real(dp) :: approx
    integer(ikind) :: Nmax
    call set_gauss_laguerre_quadrature(Nmax, points, weights)
    points(1:10) = points(1:10)/alpha

    ! Let <f(x)> = \int_{0}^{\infty} f(x) \alpha * \exp(-\alpha x) dx
    ! Check <1> = 1
    approx = sum(weights(1:Nmax))
    @assertEqual(1.0_dp, approx, tolerance=tol)
    ...

kurtsansom avatar Dec 09 '20 16:12 kurtsansom

Regarding which call failed, you have a few options. One would be to simply use a separate wrapper for each of the three calls. A bit tedious, but mostly cut-and-paste. Then the name of the wrapper will unambiguously identify which call failed. The other is to add a string argument to the inner procedures and pass a different string each time. Could even be 'A', 'B', and 'C' but something more informative would be useful. And then the call to assert has an optional argument message where you could use that string. I use that latter technique frequently where I have simple "parameterized tests":

@test
subroutine test_foo()

    call check(1.0, 2, 'case 2')
    call check(2.0, 3, 'case 3')
    call check(7.0, 5, 'case 5')

contains
   subroutine check(expected, arg, description)
       real, intent(in) :: expected,
       integer, intent(in) :: arg
       character(*), intent(in) :: description

       @assertEqual(expected, f(arg), message=description)
   end subroutine check

end subroutine test_foo

tclune avatar Dec 09 '20 17:12 tclune

Thank you @tclune that was exactly what i needed to know about the message attribute.

kurtsansom avatar Dec 09 '20 20:12 kurtsansom

Is there a good place to add an example like this in here or in the demos?

kurtsansom avatar Dec 17 '20 17:12 kurtsansom

I think adding it to the demos would be best. Just add a new subdirectory. The hard part will be choosing a meaningful name for the directory. Probably should reserve "Parameterized" for more complex example that exercises the heavy machinery.

tclune avatar Dec 18 '20 12:12 tclune