Tag, you’re it!
I have found that with any reasonably sized software project, sooner or later organization becomes important. As additional features and capabilities are introduced into a toolbox it becomes important to quickly and easily get your hands on the right piece of code for the task at hand. When we are talking about these software products, often times a piece of code does "double duty" so to speak. For these it is not uncommon that a single class or function or method is generalized to correctly serve many use cases.
How do we narrow down on these snippets of code? The tests of course! While a single bit of source code may be general enough to fulfill many use cases, the tests should be written to ensure each use case and user visible feature works correctly. In other words, if you want each use case to be covered, it had better have its corresponding tests, even if the implementation is covered via some generalized mechanism in the source code.
OK, easy enough. We'll use the tests and it will always be trivial to locate the specific tests we need right? Ummmmmm, no. Tests themselves are source code and will have the same issues with transparency as the source code they apply to. Because of this it is fairly common to see the tests themselves organized into some structure that attempts to categorize them for easy retrieval. Do I want to run the unit tests for the numeric foundations of my product? Just go to the tests/numerics/unit folder and type runtests. How about the tests that exercise these numerics through my user interface? Perhaps these are placed in the tests.system.ui test package? OK, maybe there's a lot of system testing debris in there that don't have to do with numerics but whatevs, deal with it!
What if I want to find the tests/code that covers the integration of my classification features and my persistence? For example lets say you perform a classification and save the results to some form of persistence such as a database or mat file. Well, hmm how do we find those tests? Will they be in tests/classification or tests/persistence? If they are in one folder but not the other does this mean that one of these test folders is the true "owner" of the feature and the other is not, relegated to a life of underachievement and low self-esteem?
Not so! I am here to defend these poor test folders! I am here to declare upon the rooftops that if you didn't categorize your system perfectly not all hope is lost. I understand that categorizing things correctly is very hard to do.
Can we please de-emphasize hierarchies based on folder or package structure? Instead wouldn't it be great if we could tag specific test content with meta data for use in categorization? This meta data could then apply to test content across different axes, features, or viewpoints, unlike folders or package structures. You can't place a file in two folders at the same time. Instead lets spend most of our organizational time on figuring out the best categories for our test code.
If you have R2015a or later this can be done easily using the test tagging features of the test framework.
This test tagging feature is a bit like labels in Gmail™, wherein you can apply certain labels to incoming mail messages and subsequently filter your messages using this label.
How might we apply this in our test content?
Well first let's look at a couple different axes that would be sure to get us into trouble trying to design inside a hierarchy. If we just stick with the examples already discussed, perhaps we might have some test classes that look like those below. Note these tests contain a bit of pseudocode and don't run since the product we are testing doesn't really exist.
classdef (TestTags = {'Classification', 'Persistence'}) ClassificationWithPersistenceTest < matlab.unittest.TestCase % This test exercises how two features of my toolbox work together, namely % how the toolbox classification features work with persistence handling. methods(Test) function testClassificationResultSavedCorrectly(testCase) % Classify some data and persist the result data = load('testData.mat'); classification = classify(data); classification.persist('testFile.mat'); result = load('testFile.mat'); testCase.verifyEqual(result, classification, ... 'The classification did not persist correctly in the mat file.'); end end end
Note this test class has an additional TestTags attribute on the classdef block. In that attribute you can list some tags that will help identify the intended purpose(s) of the test. In this case we can identify this test as related to both the 'Classification' and 'Persistence' features.
This is fun, lets do some more:
classdef (TestTags = {'Numerics'}) CoreNumericsUnitTest < matlab.unittest.TestCase % Here is a unit test of the core numerics of my toolbox methods(Test) function testCoreNumerics(testCase) input = 1:10; actual = CoreNumericEngine.analyze(input); expected = expectedResultFromFirstPrinciples(input); testCase.verifyEqual(actual, expected, 'RelTol', sqrt(eps)); end end end
Now we have a nice beautiful numerics unit test. How about that system test we talked about?
classdef (TestTags = {'UI'}) AcceptanceSystemTest < matlab.unittest.TestCase % A system test exercising the full stack of my app with a focus on % priority customer workflows. methods(Test, TestTags = {'Persistence'}) function testApplicationImportData(testCase) fig = numericsapp; clickImportButtonAndSelect('testFile.mat'); classification = getClassificationfromUI(fig); expected = load('testFile.mat'); testCase.verifyEqual(classification, expected, ... 'Classification data not imported correctly into the app.'); end end methods(Test, TestTags = {'Numerics'}) function testApplicationNumericResults(testCase) fig = numericsapp; clickAnalyzeButton(fig); actual = getCurrentResult(fig); expected = CoreNumericEngine.analyze(getCurrentData(fig)); testCase.verifyEqual(actual, expected, ... 'App results do not match numeric counterpart.'); end end end
This test was a bit different, as you can see the TestTags attribute is not only on the classdef block, but is included on the test methods block as well. Well, ultimately these tags apply to test methods, but including TestTags on the classdef block is a sort of shorthand for applying the tags to all of the methods of that class (and all subclasses if any). The first two test examples clearly wanted these tags applied to all methods of the class. However, system testing, in particular is a bit more nuanced. System tests can span the full feature set of a toolbox, and it is not uncommon for different tests to touch different portions of the toolbox functionality. In this case, both test methods are 'UI' but only one of them deals with 'Persistence' and one with 'Numerics'. Applying the tags at the method level allows us to differentiate these two.
Well alright, what does this give us? Test selection anyone? Let's look at our full suite containing all these tests:
import matlab.unittest.TestSuite; fullSuite = TestSuite.fromFolder('tests')
fullSuite = 1x4 Test array with properties: Name Parameterization SharedTestFixtures Tags Tests Include: 0 Parameterizations, 0 Shared Test Fixture Classes, 4 Unique Tags.
The careful reader will note that this suite's display actually shows 4 Unique Tags. If you are running in MATLAB this is actually a hyperlink that shows you the full list of tags contained in the suite. When clicked it looks something like:
Tag ________________ 'Classification' 'Numerics' 'Persistence' 'UI'
Now here's a quick way to see which tests we are working with in a suite. It is a concise way to place the names of each suite element into a column cell array which displays nicely.
{fullSuite.Name}.'
ans = 'AcceptanceSystemTest/testApplicationNumericResults' 'AcceptanceSystemTest/testApplicationImportData' 'ClassificationWithPersistenceTest/testClassificationResultSavedCorrectly' 'CoreNumericsUnitTest/testCoreNumerics'
Now we can slice and dice this suite however we want based on our tags using the nice selectIf method:
persistenceSuite = fullSuite.selectIf('Tag','Persistence'); {persistenceSuite.Name}.'
ans = 'AcceptanceSystemTest/testApplicationImportData' 'ClassificationWithPersistenceTest/testClassificationResultSavedCorrectly'
It even supports wildcards:
numericsSuite = fullSuite.selectIf('Tag','Num*'); {numericsSuite.Name}.'
ans = 'AcceptanceSystemTest/testApplicationNumericResults' 'CoreNumericsUnitTest/testCoreNumerics'
Finally, one of the huge benefits of tagging tests like this is that you don't need to commit to only one axis to categorize your test content. This example has shown adding specific tags according to features of the toolbox. However, we could also take the type of test as another axis we may want to work with. For example, if you are a fan of Google's small/medium/large test nomenclature you may want to set up a CI system to run small tests for every check-in, medium tests every night, and large tests at the end of each iteration. This deferred testing strategy becomes very easy with the aid of test tags. Let's add 'Small', 'Medium', and 'Large' tags.
classdef (TestTags = {'Classification', 'Persistence', 'Medium'}) ClassificationWithPersistenceTest < matlab.unittest.TestCase % This test exercises how two features of my toolbox work together, namely % how the toolbox classification features work with persistence handling. methods(Test) function testClassificationResultSavedCorrectly(testCase) % Classify some data and persist the result data = load('testData.mat'); classification = classify(data); classification.persist('testFile.mat'); result = load('testFile.mat'); testCase.verifyEqual(result, classification, ... 'The classification did not persist correctly in the mat file.'); end end end classdef (TestTags = {'Numerics', 'Small'}) CoreNumericsUnitTest < matlab.unittest.TestCase % Here is a unit test of the core numerics of my toolbox methods(Test) function testCoreNumerics(testCase) input = 1:10; actual = CoreNumericEngine.analyze(input); expected = expectedResultFromFirstPrinciples(input); testCase.verifyEqual(actual, expected, 'RelTol', sqrt(eps)); end end end classdef (TestTags = {'UI','Large'}) AcceptanceSystemTest < matlab.unittest.TestCase % A system test exercising the full stack of my app with a focus on % priority customer workflows. methods(Test, TestTags = {'Persistence'}) function testApplicationImportData(testCase) fig = numericsapp; clickImportButtonAndSelect('testFile.mat'); classification = getClassificationfromUI(fig); expected = load('testFile.mat'); testCase.verifyEqual(classification, expected, ... 'Classification data not imported correctly into the app.'); end end methods(Test, TestTags = {'Numerics'}) function testApplicationNumericResults(testCase) fig = numericsapp; clickAnalyzeButton(fig); actual = getCurrentResult(fig); expected = CoreNumericEngine.analyze(getCurrentData(fig)); testCase.verifyEqual(actual, expected, ... 'App results do not match numeric counterpart.'); end end end
We can even prefilter these tests in test suite creation methods like fromFolder:
smallTests = TestSuite.fromFolder('tests/v2', 'Tag', 'Small'); {smallTests.Name}.' mediumTests = TestSuite.fromFolder('tests/v2', 'Tag', 'Medium'); {mediumTests.Name}.' largeTests = TestSuite.fromFolder('tests/v2', 'Tag', 'Large'); {largeTests.Name}.'
ans = 'CoreNumericsUnitTest/testCoreNumerics' ans = 'ClassificationWithPersistenceTest/testClassificationResultSavedCorrectly' ans = 'AcceptanceSystemTest/testApplicationNumericResults' 'AcceptanceSystemTest/testApplicationImportData'
A key point here is that while this test suite is very small and manageable, this definitely does not hold in any serious software development project. Using test tags is a nice way to avoid relying too much on limited folder or package structure for test organization, instead favoring tagging each test along whichever axes it belongs to.
Then when you are looking for exactly the right set of tests to exercise exactly the right pieces of your software product......(wait for it)....
- Category:
- Testing
Comments
To leave a comment, please click here to sign in to your MathWorks Account or create a new one.