tasty-sugar
Tests defined by Search Using Golden Answer References
https://github.com/kquick/tasty-sugar
LTS Haskell 22.33: | 2.2.1.0 |
Stackage Nightly 2024-09-07: | 2.2.1.0 |
Latest on Hackage: | 2.2.1.0 |
tasty-sugar-2.2.1.0@sha256:e4ff53fb0bb6117e00987eda0bbdd16c7c856f0084d6613834d6fcac4a4e0ac5,8302
Module documentation for 2.2.1.0
- Test
- Test.Tasty
#+AUTHOR: Kevin Quick <[email protected]>
* Tasty SUGAR - Search Using Golden Answer References
The ~tasty-sugar~ package extends the tasty testing framework with
the ability to generate tests based on golden answer files found for
specific inputs. Multiple answers may be specified with different
parameterization, and there can be associated files that are
presented to the test as well.
The primary use of tasty-sugar is to _generate test cases_ based on
the contents of a directory, where the presence of various files
determine which tests are generated.
** Elements of tasty-sugar:
* Tasty.Sugar.CUBE :: Configuration Using Base Expectations
Describes the configuration for tasty-sugar tests, including
where they are located and what syntax the files should have.
* Tasty.Sugar.Sweets :: Specifications With Existing Expected Testing Samples
The tasty-sugar library uses one or more CUBE's to generate a
set of test configurations based on the existing files found,
outputting a list of Sweets representing the existing test
data.
# * KWQ: something to generate the tests
# * KWQ: name of the test runner to show the tests
# * KWQ: a test runner or option to write new outputs
* How to use tasty-sugar
Full information on tasty-sugar features and capabilities will be
provided in a later [[id:de026768-f805-4d30-8299-522cdd70926b][Detailed Information]] section, but this is a
quick introduction describing various testing use cases and how
tasty-sugar can be used in those use cases.
For these motivational use cases, the example scenario basis is that
the target to be tested is a tool to parse binary ELF files and
generate various output information about those files (e.g. similar
to objdump, but in a Haskell library form).
** Single Expected Output per Test
* Scenario :: When running a test, it generates output that should
be compared to the expected data maintained in a file.
There is a simple, single expected output for each
test, and no inputs other than the test name.
For the example scenario, several actual ELF binary files were
collected and placed in the ~test/samples~ directory:
#+BEGIN_EXAMPLE
$ ls test/samples
empty.txt
fibonacci.c
fibonacci
foo.tar
hello.c
hello
ls.c
ls
tmux.c
tmux
$
#+END_EXAMPLE
Note that there are a couple of non-ELF files in there as well, to
verify the errors generated by our library when given
The actual outputs aren't known yet, but tasty-sugar can help with
that. Setup the tasty-sugar CUBE configuration as follows:
#+BEGIN_EXAMPLE
cube = mkCUBE { inputDirs = [ "test/samples" ]
, rootName = "*.c"
, expectedSuffix = "exp"
, associatedNames = [ ("binary", "") ]
}
main = do testSweets <- findSugar cube
tests <- withSugarGroups testSweets testGroup $
\sweets expIdx expectation -> return $
testCase (rootMatchName sweets <> " #" <> show expIdx) $ do
let Just binaryName = lookup "binary" $ associated expectation
r <- runTestOn binaryName
e <- readFile $ expectedFile expectation
r @?= e
defaultMain $ testGroup "elf" tests
runTestOn :: FilePath -> IO String
runTestOn f = ...
#+END_EXAMPLE
The tasty-sugar framework does not provide the actual testing: that
is still provided by the developer. Instead, the tasty-sugar
framework reads the contents of the ~test/samples~ directory and
analyses the available files to create a list of tests that should
be run. The tasty-sugar package also provides a function that can
organize the tests and invoke the user's test function once for
each test configuration.
If the tests are run at this point, the tasty-sugar framework will
do nothing.
Why?
The tasty-sugar framework will ignore any files in the target
directory that do not have an associated expected file describing
the expected output. This can be confirmed by running the tests
with the ~--showsearch~ argument, which will use an alternate tasty
ingredient that does not actually run the tests but write out the
search process and search results.
To get actual tests to run, simply create an expected file for each
of the input candidates. The contents of the file can be empty, or
any random data.
Running the tests now will result in a test created for each input
file that has a corresponding ~*.exp~ file. Note that tasty-sweet
doesn't actually read in any of the files, just invokes the test
creation function with the Sweets and Expectation data structures
that let the test do whatever is appropriate.
#+BEGIN_EXAMPLE
$ ls test/samples
empty.txt
empty.txt.exp
fibonacci
finonacci.c
fibonacci.exp
foo.tar
foo.tar.exp
hello
hello.c
hello.exp
ls
ls.c
ls.exp
tmux
tmux.c
$
#+END_EXAMPLE
Note that ~empty.txt~ and ~foo.tar~ will be ignored, even though
there is an ~.exp~ file for them because they don't match the
source target of ~*.c~. Similarly, ~tmux~ will be ignored because
there is no ~.exp~ file for it.
At this point, any changes to the target library that cause output
changes will be identified when running the tests.
** Single Input and Output per Test
* Scenario :: Similar to the previous scenario, but there is a file
containing the expected input needed by the test to
generate the output.
To extend the previous example, let us assume that in addition to
the pre-existing binaries that we will be generating a number of
"interesting" binaries to run the target library on. These will be
kept in a different directory where a different part of the build
will compile the sources to generate the binaries for testing:
#+BEGIN_EXAMPLE
$ ls test/src_samples
foo.c
foo
foo.expct
simple.c
simple
simple.expct
recursive.rs
recursive
recursive.expct
functional.hs
functional
functional.expct
$
#+END_EXAMPLE
Note that there are several different source types (C, Rust,
Haskell) involved, but each of them has an associated output binary
that the target library should be tested on.
In an initial approach, the source files can be ignored by the
testing code: simply create a ~FILE.expct~ file for each of the
binaries and use the same ~Tasty.Sugar.CUBE~ configuration for this
directory as for the previous directory.
However however an approach where the actual test written by the
user needs access to the source file itself for some reason. This
can be handled by specifying an "associated" file in the
~Tasty.Sugar.CUBE~ configuration:
#+BEGIN_EXAMPLE
cube = mkCUBE { inputDirs = [ "test/samples" ]
, rootName = "*.exe"
, expectedSuffix = "expct"
, associatedNames = [ ("c-source", ".c")
, ("rust-source", ".rs")
, ("haskell", ".hs)
]
}
ingredients = includingOptions sugarOptions :
sugarIngredients [cube] <> defaultIngredients
main = do testSweets <- findSugar cube
tests <- withSugarGroups testSweets testGroup $
\sweets expIdx expectation -> return $
testCase (rootMatchName sweets <> " #" <> show expIdx) $ do
e <- readFile $ expectedFile expectation
let assoc = associated expectation
f = rootFile sweets
r <- case lookup "c-source" assoc of
Just c -> runCTestOn f
Nothing ->
case lookup "rust-source" assoc of
Just r -> runRustTestOn f
Nothing ->
runHaskellTestOn f
r @?= e
defaultMainWithIngredients ingredients $
testGroup "elf" tests
runCTestOn :: FilePath -> IO String
runCTestOn f = ...
runRustTestOn :: FilePath -> IO String
runRustTestOn f = ...
runHaskellTestOn :: FilePath -> IO String
runHaskellTestOn f = ...
#+END_EXAMPLE
Now when tasty-sugar generates the test configurations, each test
will have a name, a source file, an expected file, and a single
associated file. The test is free to use these files in any way it
sees fit. For the configuration above, there would be 4 test
configurations provided to the test:
| Test Name | Input File | Expected File | Associated Files |
|------------+------------+------------------+---------------------------------|
| simple | simple | simple.expct | ("c-source", "simple.c") |
| foo | foo | foo.expct | ("c-source", "foo.c") |
| recursive | recursive | recursive.expct | ("rust-source", "recursive.rs") |
| functional | functional | functional.expct | ("haskell", "functional.hs") |
Note that if both "simple.c" and "simple.hs" files existed, then the
simple test configuration would get both as associated files.
** Single Input with different parameters producing different outputs
* Scenario :: For each input file, multiple tests should be run,
each with different parameters, and the expected
output may or may not depend on the parameter.
Using the previous example scenario, let's now assume that for each
of the source sample files, two different executables were built:
one with and one without optimization. Additionally, if they were a
C source file, then there was a version built with GCC and a version
built with Clang. The output executables are now named accordingly:
#+BEGIN_EXAMPLE
$ ls test/src_samples
foo.c
foo.noopt.clang.exe
foo.O0.gcc.exe
foo.opt.clang.exe
foo.O2.gcc.exe
foo.O3.gcc.exe
simple.c
simple.noopt.clang.exe
simple.noopt.gcc.exe
simple.opt-clang.exe
simple-opt.gcc-exe
recursive.rs
recursive.noopt.exe
recursive.opt.exe
functional.hs
functional.noopt.exe
functional.opt.exe
$
#+END_EXAMPLE
While the filenames are fairly regular, there are different numbers
of executables and different naming conventions for different files.
The opt/noopt/O0/O2/O3 and gcc/clang information is known to
tasty-sugar as a "parameter". Parameters can appear in the filename
in a specific order, and each parameter may have one of a set of
valid values (e.g. gcc or clang) or it may have any (free-form)
value (as with the optimization specification).
The ~Tasty.Sugar.CUBE~ confguration for is scenario is updated to:
#+BEGIN_EXAMPLE
cube = mkCUBE { inputDirs = [ "test/samples" ]
, rootName = "*"
, separators = "-."
, expectedSuffix = "expct"
, associatedNames = [ ("c-source", ".c")
, ("rust-source", ".rs")
, ("haskell", ".hs")
]
, validParams = [
("optimization", Nothing)
,("c-compiler", Just ["gcc", "clang"])
]
}
ingredients = includingOptions sugarOptions :
sugarIngredients [cube] <> defaultIngredients
main = do testSweets <- findSugar cube
tests <- withSugarGroups testSweets testGroup $
\sweets expIdx expectation -> return $
testCase (rootMatchName sweets <> " #" <> show expIdx) $ do
e <- readFile $ expectedFile expectation
let assoc = associated expectation
f = rootFile sweets
r <- case lookup "c-source" assoc of
Just c -> runCTestOn f
Nothing ->
case lookup "rust-source" assoc of
Just r -> runRustTestOn f
Nothing -> runHaskellTestOn f
r @?= e
defaultMainWithIngredients ingredients $
testGroup "elf" tests
runCTestOn :: FilePath -> String
runCTestOn f = ...
runRustTestOn :: FilePath -> String
runRustTestOn f = ...
runHaskellTestOn :: FilePath -> String
runHaskellTestOn f = ...
#+END_EXAMPLE
Parameters are separated by designated separator characters and must
appear in the order declared. The default separators are "." and
"-" (e.g. both of the two optimized executable files for the
simple.c source above are accepted).
Filenames may omit later parameter values: the file is assumed to
apply to all unspecified parameter values if there is no more
specific override. This can be very useful to avoid repetition and
copying when specifying test files.
In the above example, a ~simple.expected~ file would be used for all
four executables, but if there was also a
~simple.noopt-gcc.expected~ and a ~simple-opt.expected~ then the
former would be used only for the ~simple.noopt.gcc.exe~ and the
latter would be used for both the ~gcc~ and the ~clang~ executables,
leaving the ~sample.expected~ to be used only for the
~simple.opt-clang.exe~ file.
** Parameter ranges
In some cases, there are a large number of possible parameter values, but the
expected files change for only some of the parameter variations. While this
could normally be handled by an expected file with no corresponding parameter
value in its name to represent the default case, this is somewhat more
difficult when there are multiple "defaults". This is handled by _parameter
ranges_.
As an example parameter range, consider a situation that might test the output
of the llvm assembler. The llvm assembler output tends to be unchanged over a
couple of versions, but then there will be a change (e.g. adding a new LLVM
assembly instruction in llvm version 14) that will change the output, but the
new format will become the default output.
Conventionally, having a ~target.ll~ assembly file as the original default
expected file means that for every version starting at 14, there will be a
specific expected file (e.g. ~target-llvm14.ll~, ~target-llvm15.ll~,
~target-llvm16.ll~, ...) even though all of those files are identical.
To address this, the parameter values can specify an upper or lower range limit
and use the ~rangedParamAdjuster~ to adjust the Expectations generated. Using
this, the expected files could be something like ~target-llvm_pre12.ll~,
~target-llvm_pre14.ll~, ~target.ll~, along with an indication that the
parameter value of ~llvm_preN~ formed an upper limit. When used this way, the
~target-llvm_pre12.ll~ file will be chosen for versions 10 and 11 of llvm, the
~target-llvm_pre14.ll~ file would be chosen for versions 12 and 13 of llvm, and
the ~target.ll~ file would be chosen for llvm version 14 or higher.
For more information, see the documentation for the ~rangedParamAdjuster~
helper function, and the corresponding tests.
# ** Multiple Inputs with different parameters producing different outputs
#
# KWQ...
* Comparisons
** tasty-KAT
* The tasty-KAT package reads both the inputs and the outputs from a
single file, instaed of allowing the inputs to be a separate file
that can be processed by the target under test.
+ The tasty-sugar package allows inputs and outputs to be in
separate files, and additional "associated" files to be provided
as inputs to the test.
* The tasty-KAT package inputs and outputs must be specifiable in a
file with other KAT markup; this does not easily handle text
markup conflicts and binary inputs/outputs.
+ The tasty-sugar package does not attempt to interpret the
contents of the files, but simply passes them to the test
itself.
* The tasty-KAT package does not allow auxiliary files, or different
parameterized tests.
+ As mentioned above, tasty-sugar allows multiple auxiliary files
per tests, and allows test inputs and expected outputs to be
filename parameterized (with either constrained or free-form
parameter values).
** tasty-golden
* The tasty-golden package requires a 1:1 association between tests
and corresponding golden expected output files; it does not
support file-provided inputs, or associated files.
+ The tasty-sugar package allows multiple associated files in
addition to the primary input file.
+ The tasty-sugar package supports parameterization of expected
results (and associated files) as part of the filenames to allow
multiple tests per input.
* The tasty-golden package will write the expected results if the
expected file is missing.
+ The tasty-sugar package does not actually read or write any of
the files identified, it simply provides the names of the files
to the test generator function. The user's test code is
responsible for all file processing.
** tasty-silver
Similar to tasty-golden in functionality.
** Features unique to tasty-sugar
* Multiple potential outputs, parameterized by filename elements.
* Multiple associated input files.
* Search analysis mode showing how tests are generated based on the
available files.
* Automatic grouping of generated tests by parameter values.
* Limitations
* Huge numbers of directories or files will cause performance slowdowns
* Will throw any exception that the listDirectory function can throw.
* Detailed Information
:PROPERTIES:
:ID: de026768-f805-4d30-8299-522cdd70926b
:END:
** Requirements
* There must be a root (input) file to feed to the test
* There must be one or more "expected" results files for a root file
* There may be associated files for the root file required for the test
* All three groups of files may be parameterized by additional fields.
* All fields are represented by a common basename with optional
parameters and required associated suffixes, separated by
allowable separators.
All of the above may utilize globbing as provided by System.FilePath.Glob
* Examples
** Example:
For example, a test which would verify that the size of a compiled
file meets the expectations would specify:
#+BEGIN_EXAMPLE
CUBE =
{ inputDirs = [ "tests/samples" ] -- relative to cabal file
, separators = ".-"
, rootName = "*.c"
, associatedNames = [ ("exe", "exe")
, ("object", "o")
]
, expectedSuffix = "expected"
, validParams = [ ("arch" : Just ["ppc", "x86_64"]) ]
}
#+END_EXAMPLE
And given the following directory configuration:
#+BEGIN_EXAMPLE
tests/samples/
foo.c
bar.c
bar.exe
bar.ppc.exe
bar.expected
cow.c
cow.ppc.exe
cow.x86_64.exe
cow.expected
cow.ppc-expected
cow.x86.expected
moo.c
moo.exe
moo-expected
dog.exe
dog.expected
#+END_EXAMPLE
The result would be:
#+BEGIN_EXAMPLE
sweets =
[ Sweets
{ rootMatchName = "bar"
, rootBaseName = "bar"
, rootFile = "tests/samples/bar.c"
, expected =
[ Expectation
{ expectedFile = "tests/samples/bar.expected"
, associated = [ ("exe", "tests/samples/bar.exe") ]
, expParamsMatch = []
}
, Expectation
{ expectedFile = "tests/samples/bar.expected"
, associated = [ ("exe", "tests/samples/bar.ppc.exe") ]
, expParamsMatch = [ ("arch", "ppc") ]
}
]
},
, Sweets
{ rootMatchName = "cow"
, rootBaseName = "cow"
, rootFile = "tests/samples/cow.c"
, expected =
[ Expectation
{ expectedFile = "tests/samples/cow.ppc-expected"
, associated = [ ("exe", "tests/samples/cow.ppc.exe") ]
, expParamsMatch = [ { "arch", "ppc" } ]
}
, Expectedfile
{ expectedFile = "tests/samples/cow.expected"
, associated = [ ("exe", "tests/samples/cow.x86_64.exe") ]
, expParamsMatch = [ ("arch", "x86_64") ]
}
]
},
, Sweets
{ rootMatchName = "moo"
, rootBaseName = "moo"
, rootFile = "tests/samples/moo.c"
, expected =
[ Expectation
{ expectedFile = "tests/samples/moo-expected"
, associated = [ ("exe", "tests/samples/moo.exe") ]
, expParamsMatch = []
}
]
},
#+END_EXAMPLE
* FAQ
Why do the configurations need to be described by a ~Tasty.Sugar.CUBE~
data object? Why can't they be passed in on the command-line?
* Answer :: They could be, but there are a couple of issues that
would make that more awkward:
1. There would need to be a number of command-line
arguments to describe all of the CUBE information.
2. The tasty framework provides command-line parsing and
argument handling (and expects to do so). Handling
some command-line arguments prior to tasty and some
within tasty would be difficult and brittle (and also
note that the set of all tests must be known *prior*
to invoking the tasty main code; they cannot be added
dynamically after that point).
3. Testing should be consistent: relying on user-input during
testing would return different results for different input.
Changes
Revision history for tasty-sugar
2.2.1.0 – 2023-06-28
- Allow optparse-applicative 0.18, tasty 1.5, and hedgehog 1.4 packages.
2.2.0.0 – 2023-05-03
- Added
rangedParamAdjuster
helper function. - Added
sweetAdjuster
field toCUBE
. - The
findSugarIn
function is now monadic with a MonadIO constraint (ato support sweetAdjuster functionality). - Exports
candidateToPath
, which was added in a previous version but not made publicly available. - The
--showsearch
output is switched to sorted order and provides a (correct) total count. - Parameter names are included in the test names along with the value.
2.1.0.0 – 2023-03-20
- Now supports the ability for the expected file to have the same name as the
root file. This is a trivial match, but still allows for capture of
parameters and associated files. Major version bump because this may result
in additional, unexpected matches; to get the original behavior use the (new)
distinctResults
modifier on thefindSugar
results. - Support for GHC 9.6
2.0.1.0 – 2023-01-09
- Support for GHC 9.4.
2.0.0.1 – 2022-11-10
-
Fixed arithmetic underflow exception. This only happened when an unconstrained parameter value was used and a subdirectory matched a constrained parameter value.
-
Fixed test group name for unconstrained parameters to use the actual parameter value (if available).
2.0 – 2022-11-07
-
Performance improvements. Now scales better when there are more parameters.
-
Improved expected and associated file matching and Expected results parameter identification. This may result in different results than the 1.x version of tasty-sugar!
-
Parameter values explicit in the root match but not in the expected file are now reported as Explicit in the expParamsMatch because they explicitly match part of one of the filenames. This also means that files which were previously provided as expectations are no longer matched because the better root parameter matches exclude them.
-
Expectations are selected by best ParamMatch values, in order of parameter name, with ties resolved in favor of the longest expected filename.
-
-
The –showsearch can now also report internal stats for tasty-sugar.
1.3.0.2 – 2022-09-06
- Add test files to cabal’s
extra-sources
so they are included in the package (thanks to Felix Yan).
1.3.0.1 – 2022-06-29
- Bump optparse-applicative upper bound to allow 0.17 series.
1.3.0.0 – 2022-06-28
-
Add
inputDirs
toCUBE
to replace singleinputDir
. Allows test action files to be spread across various directories. TheinputDir
is still supported for backward-compatibility, but theinputDirs
is preferred. -
Any input directory can be specified with a trailing asterisk (e.g. “test/data/*”) in which case all subdirectories of that root directory will be scanned.
-
Any file (root, expected, or associated) may come from any directory (the files in a single Sweet Expectation do not all need to occur in the same subdirectory).
-
The parameter matching will also consider subdirectory names (prioritized over filename parameter matches).
-
-
SweetExplanation
results
field is a single entry instead of an array. -
Added
getParamVal
function to extract the value (if any) from anyParamMatch
. -
Fixed some bugs in filename analysis and parameter determination.
1.2.0.0 – 2022-05-16
- Update for logict 0.8.0.0 breaking change.
- Fix small issues in example usage haddock.
1.1.1.0 – 2021-04-19
- Use ‘kvitable’ to render
--showsearch
output. - Update build warnings and refactor code to remove potential partial results.
- Fix tasty ingredients option double-defaulting.
- Add dependency constraint bounds.
- Update haddock usage for usage changes.
1.1.0.0 – 2021-01-31
- Allow multiple tests to be generated for each
Expectation
viawithSugarGroups
function. ThewithSugarGroups
third argument function (the test generator) now returns a list instead of a single test. While roughly the same effect could have been achieved by the test generator function using atestGroup
to wrap multiple tests, this change allows both (a) multiple tests to be generated without requiring another level in the test heirarchy, and (b) the generator can return an empty list if there should not be any tests generated for the specifiedExpectation
.
1.0.2.0 – 2021-01-31
-
Export
paramMatchVal
utility function -
Fix over-trimming of
Expectation
matches -
Fix identification ranking of associated files
1.0.1.1 – 2021-01-18
- Fix error where an expected set of matches could match multiple root names where the root names contain separators.
1.0.1.0 – 2021-01-18
-
Associated files are now ranked based on the number of parameter components in the name and only the highest number of matches are provided. Previously all possible matches were supplied, which meant that “more generic” associations caused an Expectation in addition to the “more specific” associations. Now only the “more specific” assocations cause an Expectation.
-
Better indentation on Expectation information for –showsearch.
1.0.0.0 – 2021-01-14
-
Allow multiple CUBE inputs for a single test session; Sweets are a semigroup.
-
Run the withSugarGroups mkSweets in MonadIO instead of pure.
-
Remove blank lines in –showsearch output.
0.2.0.0 – 2021-01-12
-
Renamed CUBE “source” to “rootName”
-
Updated Sweets structure to show root match base and match name
-
Rewritten implementation using Logic capabilities. Clarified many corner cases and fully implemented all logic.
-
Significantly enhanced testing.
-
Updated documentation.
0.1.0.0 – 2019-12-24
- Initial version.