Unit Testing with matlab.unittest
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
Written against MATLAB R2026b. MATLAB’s unit testing framework (the matlab.unittest namespace, with runtests, testsuite and testrunner) is not part of Octave 11.3.0, so no test file on this page was executed as a test. What was run in Octave 11.3.0 is the code under test, heatflow.laplacian and heatflow.stableTimeStep, including the floating-point comparison that decides why tolerances matter; those blocks are labelled "Octave 11.3.0 output". Every test file, runtests call and coverage call is labelled "not run here" and was checked line by line against the R2026b documentation. Release numbers are from the version history of the reference pages. Test fixtures, parameterised tests, tags and mocking are on Test Fixtures, Parameterization and Mocking; running the same tests from a build file is on Build Automation with buildtool.
A unit test runs a small piece of code and checks the result against an expectation, so that a later change that breaks the code is noticed. The documentation states the purpose as guiding software development and monitoring for regressions. The framework is xUnit-style: a test is a function or method that receives a test case object, uses it to qualify values, and the framework runs many tests independently and collects every failure rather than stopping at the first.
The code under test
The tests on this page check two small functions of the heatflow namespace, kept in the project’s src folder (which is on the MATLAB path) as src/+heatflow/laplacian.m and src/+heatflow/stableTimeStep.m. laplacian is the five-point approximation of the second derivatives at the interior points of a temperature field with grid spacing h; stableTimeStep is the stability limit of the explicit scheme, for a diffusivity .
function L = laplacian(T,h)
%LAPLACIAN Five-point Laplacian of the interior points of a temperature field.
if h <= 0
error("heatflow:laplacian:badSpacing","Spacing h must be positive.")
end
L = (T(1:end-2,2:end-1) + T(3:end,2:end-1) ...
+ T(2:end-1,1:end-2) + T(2:end-1,3:end) ...
- 4*T(2:end-1,2:end-1)) / h^2;
end
function dt = stableTimeStep(h,alpha)
%STABLETIMESTEP Largest stable explicit time step, h^2/(4*alpha).
dt = h^2 / (4*alpha);
end
The output has two fewer rows and columns than the input. For a constant field the Laplacian is zero. For the quadratic field the exact Laplacian is 4 everywhere, and the five-point formula reproduces it exactly in exact arithmetic. In floating-point arithmetic it does not, which is the lesson of the next block (the output is Octave’s; MATLAB’s disp layout can differ):
h = 0.1;
[X,Y] = meshgrid(0:h:1);
Tconst = 300*ones(6,7);
Tquad = X.^2 + Y.^2;
L0 = heatflow.laplacian(Tconst,h);
disp(size(L0))
disp(all(L0(:) == 0))
L2 = heatflow.laplacian(Tquad,h);
disp(size(L2))
disp(all(L2(:) == 4))
disp(max(abs(L2(:) - 4)) <= 1e-9)
fprintf("%.3g\n", max(abs(L2(:) - 4)))
fprintf("%g\n",heatflow.stableTimeStep(0.1,1e-4))
try
heatflow.laplacian(Tconst,-1);
catch err
disp(err.identifier)
disp(err.message)
end
Octave 11.3.0 output:
4 5
1
9 9
0
1
9.15e-14
25
heatflow:laplacian:badSpacing
Spacing h must be positive.
The test for exact equality with 4 is false, but the largest deviation is about , and a tolerance of 1e-9 accepts it. A test that compares floating-point results with == fails for a correct function. The MATLAB documentation says the same about its own example: the script-based triangle tests fail because the floating-point comparisons need a tolerance.
Three ways to write tests
The framework supports three authoring schemes, each a superset of the previous in features. Test files are found by name: the file name must start or end with test, case-insensitively, or the tests may be ignored.
| Script-based | Function-based | Class-based | |
|---|---|---|---|
Unit of a test |
A section of a script, starting with |
A local function whose name starts or ends with |
A method in a |
Entry point |
The script file itself |
A main function with the file’s name that returns |
The class definition file |
Qualifications |
Only |
The whole |
The same as function-based |
Setup and teardown |
Code before the first |
|
|
Parameterised tests, tags, inheritance |
No |
No |
Yes |
Good for |
Quick checks and first tests; people who write scripts |
Most tests of functions; low ceremony, still a full qualification library |
Larger suites, fixtures shared across files, parameters, tags, reuse by subclassing |
A reasonable rule is to start function-based, because it needs no class knowledge and has the whole qualification library, and to move to class-based when you need parameters, shared fixtures, tags or inheritance. Script-based tests are the entry point: they need nothing but assert and %%.
Script-based tests
The documentation lists the conventions of a test script. Each %% section is a separate test. The code before the first %% is the shared variables section: every test sees these variables with the values defined there, and a modification inside a test does not carry over to the next. If that section fails (for example an assert in it), no test runs and they are all marked failed and incomplete. Variables defined in one test are not visible in another. A script with no %% sections is a single test named after the file, and then the first failure halts it.
% laplacianScriptTest.m -- script-based tests (not run here)
h = 0.1;
[X,Y] = meshgrid(0:h:1);
% precondition: if this fails, no test is run
assert(isequal(size(X),[11 11]),"Fundamental problem: unexpected grid")
%% Test constant field has zero Laplacian
L = heatflow.laplacian(300*ones(6,7),h);
assert(isequal(L,zeros(4,5)))
%% Test quadratic field has Laplacian 4
L = heatflow.laplacian(X.^2 + Y.^2,h);
assert(max(abs(L(:) - 4)) <= 1e-9,"Laplacian of x^2+y^2 must be 4")
%% Test non-positive spacing is rejected
id = "none";
try
heatflow.laplacian(ones(5),-1);
catch err
id = err.identifier;
end
assert(id == "heatflow:laplacian:badSpacing")
Run it with runtests, not by pressing Run: executing the file as a script stops at the first failed assert, whereas runtests runs every section individually and keeps going after a failure. Script-based tests have only assert, so a failed comparison gives a short message (Assertion failed) unless you pass your own message, as the second assert does, and a tolerance has to be written by hand, as above. Because an assert that fails throws an error, the test is marked both Failed and Incomplete. Script tests can still be selected with the classes of matlab.unittest.selectors once an explicit suite exists, and run by a TestRunner with plugins.
Function-based tests
A function-based test file has a main function named like the file that returns the array of tests, and one local function per test. localfunctions collects handles to the local functions, and functiontests turns the cell array of handles into the tests. A local function whose name does not start or end with test is a helper or a fixture, not a test. The order of the tests in the file does not matter because tests must be independent.
function tests = laplacianFunctionTest
% laplacianFunctionTest.m -- function-based tests (not run here)
tests = functiontests(localfunctions);
end
function setup(testCase)
h = 0.1;
[X,Y] = meshgrid(0:h:1);
testCase.TestData.h = h;
testCase.TestData.X = X;
testCase.TestData.Y = Y;
end
function constantFieldTest(testCase)
actual = heatflow.laplacian(300*ones(6,7),testCase.TestData.h);
verifyEqual(testCase,actual,zeros(4,5))
end
function quadraticFieldTest(testCase)
d = testCase.TestData;
actual = heatflow.laplacian(d.X.^2 + d.Y.^2,d.h);
verifyEqual(testCase,actual,4*ones(9,9),AbsTol=1e-9)
end
function badSpacingTest(testCase)
verifyError(testCase,@() heatflow.laplacian(ones(5),-1), ...
"heatflow:laplacian:badSpacing")
end
function stableStepTest(testCase)
verifyEqual(testCase,heatflow.stableTimeStep(0.1,1e-4),25,RelTol=1e-12)
end
The first input of every test and fixture function is a matlab.unittest.FunctionTestCase object, which the framework creates. It is the object you pass to the qualification functions (verifyEqual(testCase,...)), and its TestData structure is how a setup function hands data to a test. Four fixture functions are recognised by name: setupOnce and teardownOnce run once per file, and setup and teardown run around each test. The documentation prefers the per-test pair, because it keeps each test self-contained, and asks that teardown actions mirror the setup actions in reverse order. The test file in this example uses setup because it needs a fresh grid, which is cheap. The extra feature of function-based tests over script-based ones is the full qualification library, including applyFixture to use the fixture classes (see the next page), log for diagnostics, constraints and tolerances.
Class-based tests
A class-based test derives from matlab.unittest.TestCase and writes each test as a method in a methods (Test) block. The first input of a Test method is the TestCase object, which the method may ignore if it does not need it. The same tests as a class:
classdef LaplacianTest < matlab.unittest.TestCase
% LaplacianTest.m -- class-based tests (not run here)
properties
H = 0.1
end
methods (Test)
function constantField(testCase)
actual = heatflow.laplacian(300*ones(6,7),testCase.H);
testCase.verifyEqual(actual,zeros(4,5))
end
function quadraticField(testCase)
[X,Y] = meshgrid(0:testCase.H:1);
actual = heatflow.laplacian(X.^2 + Y.^2,testCase.H);
testCase.verifyEqual(actual,4*ones(9,9),AbsTol=1e-9)
end
function badSpacing(testCase)
testCase.verifyError(@() heatflow.laplacian(ones(5),-1), ...
"heatflow:laplacian:badSpacing")
end
end
end
The framework gives every Test method its own copy of the test case object, so changing testCase inside one method (for instance a property) never leaks to another. The documented sequence for a test class is: create the class-level test case, run the TestClassSetup methods, then for each test copy the test case, run the TestMethodSetup methods, run the test, run the method-level teardown, and discard the copy; finally run the class-level teardown. Shared test fixtures set up before all the test classes that declare the same ones and are torn down after them. Teardown, fixtures, parameters and tags are explained on the next page.
The documentation asks for tests that are independent and repeatable. That means restoring any global state that a test changes (the path, the current folder, a display format, rng, a figure) by registering the restoring action next to the change, in the reverse order of the setup.
The TestCase class
matlab.unittest.TestCase is the superclass of all test classes and provides the interface to write and identify test content, including fixture setup and teardown. A FunctionTestCase (function-based) is also a test case. Besides the qualification methods, it supplies addTeardown and applyFixture (next page), log (record a diagnostic at a verbosity level), forInteractiveUse (a test case object for trying qualifications at the command prompt, which prints "Verification passed" or the failure text) and the TestData property of the function-based flavour. Because TestCase is a class, normal object-oriented tools apply: a test class can inherit from another test class to reuse its tests, parameters and methods.
testCase = matlab.unittest.TestCase.forInteractiveUse;
verifyEqual(testCase,1.5,2,AbsTol=1)
(Not run here.) The documentation example for verifyEqual shows that, with these arguments, the verification passes: the difference 0.5 is within the tolerance 1.
Qualifications
A qualification is a check inside a test. There are four kinds, which differ in what a failure does:
| Kind | On failure | Use it for |
|---|---|---|
Verification ( |
Records the failure and returns; the rest of the test, and the other tests, keep running |
The result you are testing. This is the normal choice, because one run reports every problem. |
Assumption ( |
The test is marked filtered (incomplete), not failed |
Preconditions about the environment that are not code defects: an operating system, a toolbox licence, a feature that does not exist in this release. |
Assertion ( |
The current test is marked failed and incomplete and stops; later tests run |
Preconditions of this test that, if false, make the rest of the test meaningless. |
Fatal assertion ( |
The framework aborts the whole test session, and no result object is produced |
A condition so broken that continuing is pointless, such as an unrecoverable state left by setup. |
The kinds share one naming convention, <qualify>Name, so verifyTrue, assumeTrue, assertTrue and fatalAssertTrue exist, and the same for every row below. The plain assert function of script-based tests is not one of these methods: it throws an error, which the framework turns into a failure and an incomplete test.
| Check | Methods (prefix with verify, assume, assert or fatalAssert) |
|---|---|
Truth and equality |
|
Errors and warnings |
|
Inequalities |
|
Array shape |
|
Types |
|
Text |
|
Every qualification takes an optional diagnostic argument, text or a function handle, which is shown with the framework’s own message when the qualification fails. verifyEqual(testCase,actual,expected,"Classes must match.") is a usual example. The framework’s message for verifyEqual already lists the sizes, classes and a failure table of actual, expected, error and relative error, so the diagnostic should add the reason for the check, not repeat the values.
An assumption in a test class, which skips (filters) the test when an optional product is absent, can be written with a license check:
function usesParallelPool(testCase)
testCase.assumeTrue(logical(license("test","Distrib_Computing_Toolbox")), ...
"Parallel Computing Toolbox is not licensed")
% ... the test body runs only when the assumption holds
end
(Not run here.) A filtered test appears in the results as incomplete but not failed, and the documentation advises monitoring filtered tests, because too many of them mean code that is never exercised.
Tolerances
verifyEqual is strict: if expected is not an object, actual and expected must have the same class, size and value, so verifyEqual(testCase,int8(5),int16(5)) fails on the class, and verifyEqual(testCase,[5 5],5) fails on the size. For floating-point numbers, add a tolerance as a name-value argument, which exists for verifyEqual, assumeEqual, assertEqual and fatalAssertEqual alike:
| Argument | The qualification passes when |
|---|---|
|
|
|
|
The size of the tolerance must match, or be compatible with, the size of expected, so a scalar tolerance applies to the whole array, and a tolerance only applies to values of the same data type. The documentation example verifyEqual(testCase,1.5,2,AbsTol=1) passes, while with RelTol=0.1 the same comparison fails because 0.5 is more than 10% of 2. An absolute tolerance is the right choice near zero and for quantities with a physical scale, such as the temperature error of a field in kelvin; a relative tolerance is right when the size of the value varies, such as stableTimeStep across grids. Pick a tolerance from the accuracy that the algorithm promises and that the data type allows, and not from the smallest number that passes. See the reference page of verifyEqual for combining both tolerances and for the constraint-based form (verifyThat with IsEqualTo and matlab.unittest.constraints.AbsoluteTolerance or RelativeTolerance), which is also what lets a tolerance be reused across tests. In R2021a and earlier the name-value arguments of the qualification methods went before the diagnostic; in later releases they go after all other arguments.
Running tests with runtests
runtests creates a suite, runs it and returns the results. Its forms are testResults = runtests (every test in the current folder), testResults = runtests(tests) (a file name, folder, class or namespace, or a suite) and testResults = runtests(___,Name=Value). Since R2023b, [testResults,coverageResults] = runtests(___) also returns coverage when ReportCoverageFor is given. The documented arguments you use most:
| Name-value argument | Effect |
|---|---|
|
Also run tests in subfolders. Without it, a test in a subfolder is not run. |
|
Include tests of referenced projects. |
|
What to do for a test file that cannot run, such as one with a syntax error. |
|
Select tests by name, procedure, tag, parameter or folder. The wildcards |
|
Select tests that depend on given source files. Needs a MATLAB Test licence. |
|
Treat a warning issued by a test as a qualification failure. |
|
Run groups of tests in parallel. |
|
Enter the debugger when a test fails. Forces serial execution. |
|
How much event detail is printed ( |
|
Run the tests and show the results in the Test Browser app. |
|
Files or folders for a code coverage report (below). |
|
Create or update baseline data; needs MATLAB Test or Simulink Test. |
results = runtests; % current folder
results = runtests("LaplacianTest"); % one test file or class
results = runtests(pwd,IncludeSubfolders=true); % folder tree
results = runtests("LaplacianTest",Name="*Field*"); % by test name
results = runtests(pwd,UseParallel="auto"); % R2026b form
results = runtests(pwd,Strict=true,OutputDetail="Verbose");
(Not run here.) For a test on the heatflow scenario, Name="*Field*" would select constantField and quadraticField. Tests that share state through the path, files or a figure are the usual reason a parallel run behaves differently from a serial one: the framework may vary the number and order of the groups, so tests must not depend on each other. Parallel Computing Toolbox is covered, as a pointer, on Toolboxes and Add-On Products.
runtests is the convenient front end. The pieces it uses are available directly for control: testsuite builds a suite (and takes the same selection arguments), testrunner creates a runner, optionally with the "textoutput" plugin set, addPlugin adds plugins, and run(runner,suite) runs it. runInParallel runs a suite in parallel.
The Test Browser
The Test Browser app (since R2023a) runs script-based, function-based and class-based tests interactively. Open it with the command testBrowser, add tests from files, folders or the current project, and the suite appears as a tree: a parent node per test file and a child per test. Each test shows Not Run until it runs, then Passed, Failed or Incomplete, and the toolbar counts each status. From the browser you can run all or part of the suite, see the diagnostics, debug a failing test, set run options (parallel, output detail) and generate the HTML coverage report. Statuses of tests from earlier runs are dimmed after a new run (since R2025a), the current file’s tests can be added directly (since R2024b), and referenced projects can be included (since R2026a).
You can also run tests from the Editor or the Live Editor (the Run Tests section of the toolstrip). The documentation notes that the settings of runtests and of the Test Browser are independent and not synchronised, so the results of the same suite can differ if the options differ. From R2026b, runtests(UseTestBrowser=true) runs the tests and shows the results in the browser, and the command shown in the Command Window when you run from the toolstrip includes this argument.
Test results
runtests returns a matlab.unittest.TestResult array, one element per test run. The documented properties are Name, Passed, Failed, Incomplete, Duration and Details. Passed is true only when the test finished as expected, Failed is true after a verification failure, an assertion failure or an uncaught exception (in the test or in fixture setup and teardown), and Incomplete is true when the test did not run to completion, for example after an assertion or a filtered assumption. A fatal assertion aborts the session instead, and no result is produced. Ordinary MATLAB indexing works on the array:
results = runtests("LaplacianTest");
numel(results) % number of tests that ran
failed = results([results.Failed]);
{failed.Name}' % names of the failed tests
t = table(results) % one row per test
assertSuccess(results) % errors if anything failed (R2020a)
generateHTMLReport(results) % report from the results (R2022a)
(Not run here.) Among the methods of TestResult: table converts the array to a table; assertSuccess (since R2020a) asserts that the run had no failing condition, which is the line to put in a script that must stop a pipeline; and generateDOCXReport, generateHTMLReport and generatePDFReport (since R2022a) produce reports from part or all of the results. The text display of runtests ends with totals (passed, failed and incomplete) and the testing time, as in the documentation example "5 Passed, 0 Failed, 0 Incomplete". CI pipelines usually need a machine-readable format; the plugin classes in the matlab.unittest.plugins namespace can produce them, and the Cobertura coverage format shown below is one example; this is on Continuous Integration.
Code coverage
Coverage tells which code the tests executed. Base MATLAB reports statement coverage (the statements executed at least once, where an if or elseif condition and each call count as a unit) and function coverage (which functions or class methods were called). Decision, condition and MC/DC coverage need a MATLAB Test licence. There are three ways to ask for it.
Through runtests, with ReportCoverageFor naming the source files or folders (.m, .mlx or .mlapp); relative paths must be in the current folder. The call writes an HTML report, and a second output gives programmatic access (since R2023b):
[results,cov] = runtests("LaplacianTest", ...
ReportCoverageFor=fullfile(pwd,"src","+heatflow"));
(Not run here.) With a plugin on a test runner, which is the form that also works for CI formats such as Cobertura XML (matlab.unittest.plugins.codecoverage.CoberturaFormat):
import matlab.unittest.plugins.CodeCoveragePlugin
import matlab.unittest.plugins.codecoverage.CoverageResult
runner = testrunner("textoutput");
format = CoverageResult;
plugin = CodeCoveragePlugin.forFile("src/+heatflow/laplacian.m",Producing=format);
addPlugin(runner,plugin)
run(runner,testsuite("LaplacianTest"));
result = format.Result;
coverageSummary(result,"statement") % [executed total]
coverageSummary(result,"function")
generateHTMLReport(result)
(Not run here.) The documentation shows that coverageSummary returns a vector of the number executed and the total, for example 8 of 17 statements, and that coverage results can be added (result1 + result2) to aggregate runs. The third way is the Test Browser, which has a coverage option. The use of coverage is to find untested branches, not to chase a percentage: in laplacian the if h <= 0 branch is covered only by the bad-spacing test, which is why that test exists.
Pitfalls
-
Comparing floating point with
==or defaultverifyEqual. The Octave run above shows a correct function returning 4 within of 4. UseAbsTolorRelTol. -
Code under test not on the path. If
srcis not on the path, every test errors with "Unable to resolve the name 'heatflow.laplacian'." (identifierMATLAB:undefinedVarOrClass) rather than failing a qualification. Add the folder with aPathFixture(next page) or open the project. -
A test file whose name does not start or end with
test. The framework may ignore it, with no error. The same holds for local test functions in function-based files. -
Running a script-based test by pressing Run. It stops at the first failed
assert; useruntests. -
Using
assertfor the result. An assertion stops the test at the first failure, so a later problem stays hidden; useverify*for results and keepassert*for preconditions. -
Tests that depend on each other or on global state. Each test case object is an independent copy, but the path, current folder, random number generator state and open figures are not. Restore them with
addTeardown, next page. -
Uncaught exceptions in setup. A failure in a setup method marks the tests as failed; an assumption failure in
TestClassSetupfilters the entire class. -
Name-value arguments in the wrong place. They go after all positional arguments (R2021a and later).
-
Handle objects as shared test data. A mutation by one test is visible to the next unless it is rebuilt per test.
-
Counting filtered tests as passes. Filtered tests are incomplete, not passed. Look at the totals.
Where older books differ
The two books consulted for this section do not treat the unit testing framework as a topic (the concept checklist for this section has no testing chapter), so code in their style is checked ad hoc: scripts that print values for the reader to compare, or assert and error calls in a script. The framework described here is the standard replacement, and several of its present forms are newer than old tutorials and blog posts: name-value syntax with = in runtests(IncludeSubfolders=true) (R2021a), UseParallel=true replaced by "off", "auto" and "on" (R2026b, the old values still work), the Test Browser app (R2023a), the coverageResults output (R2023b), and the DOCX, HTML and PDF reports from results (R2022a). Old code that calls runtests('file.m') with comma-separated name-value pairs still works. The Code Analyzer and codeIssues for static checks are on Code Analyzer and Code Quality.
The same idea in other languages: test functions and classes discovered by a runner, assertions, and coverage are the subject of Python: Unit Testing with pytest & Mocking and Java: Testing with JUnit 5 and Mockito.