Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,12 @@ plan("runExample") = ExampleDrivenTesterTask(["examples", "doc"], CodeCoveragePl
plan("runExample") = ExampleDrivenTesterTask(["examples", "doc"], CleanupFcn = @() bdclose('all'));
```

6. Enable incremental build support by specifying `SourceFiles`. The task will only re-run when source or example files change:
```matlab
plan("runExample") = ExampleDrivenTesterTask(["examples", "doc"], SourceFiles = "src");
```
Without `SourceFiles`, the task runs every time (same as the built-in `TestTask`).

## License

The license is available in the [LICENSE.txt](license.txt) file within this repository
Expand Down
4 changes: 3 additions & 1 deletion buildfile.m
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@
% Run MATLAB scripts from specified folder and generate a code coverage report
reportFormat = matlab.unittest.plugins.codecoverage.CoverageReport('coverage-report');
covPlugin = matlab.unittest.plugins.CodeCoveragePlugin.forFolder("toolbox/sampleToolbox/code", "Producing", reportFormat);
plan("runExample") = ExampleDrivenTesterTask("toolbox/sampleToolbox/examples", CodeCoveragePlugin = covPlugin);
plan("runExample") = ExampleDrivenTesterTask("toolbox/sampleToolbox/examples", ...
SourceFiles = "toolbox/sampleToolbox/code", ...
CodeCoveragePlugin = covPlugin);

plan.DefaultTasks = "test";

Expand Down
195 changes: 195 additions & 0 deletions tests/tExampleDrivenTesterTask.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
classdef tExampleDrivenTesterTask < matlab.unittest.TestCase
% Test verifies ExampleDrivenTesterTask buildtool integration
% including incremental build support.

properties (Access=private)
ExamplesFolder string
SourceFolder string
end

methods (TestClassSetup)
function pathSetup(testCase)
import matlab.unittest.fixtures.PathFixture;
import matlab.unittest.fixtures.CurrentFolderFixture
testCase.applyFixture(PathFixture("../toolbox"));
testCase.applyFixture(PathFixture(fullfile("../toolbox", "internal")));
testCase.applyFixture(CurrentFolderFixture("tExamplesTester_files"));
testCase.applyFixture(PathFixture("code"));
testCase.ExamplesFolder = fullfile(pwd, "examples");
testCase.SourceFolder = fullfile(pwd, "source");
end
end

methods (TestMethodSetup)
function ensurePath(testCase) %#ok<MANU>
% buildplan/run may remove paths when it opens/closes the project.
% Re-add them before each test to ensure ExampleDrivenTesterTask
% is always on the path.
toolboxDir = fullfile(fileparts(fileparts(mfilename('fullpath'))), "toolbox");
internalDir = fullfile(toolboxDir, "internal");
addpath(toolboxDir);
addpath(internalDir);
end
end

methods (Test)

function verifyInputsTrackFiles(testCase)
% Verify that task Inputs use file globs when SourceFiles is provided
task = ExampleDrivenTesterTask(testCase.ExamplesFolder, ...
SourceFiles=testCase.SourceFolder);
inputPaths = task.Inputs.paths;
testCase.verifyGreaterThan(numel(inputPaths), 0, ...
"Task Inputs should resolve to actual files inside folders");
end

function verifyNoIncrementalBuildWithoutSourceFiles(testCase)
% Verify that without SourceFiles, incremental build is disabled
task = ExampleDrivenTesterTask(testCase.ExamplesFolder);
testCase.verifyEmpty(task.SourceFiles, ...
"SourceFiles should be empty when not provided");
end

function verifyOutputsSetWhenSourceFilesProvided(testCase)
% Verify that Outputs is set when SourceFiles is provided
task = ExampleDrivenTesterTask(testCase.ExamplesFolder, ...
SourceFiles=testCase.SourceFolder);
testCase.verifyNotEmpty(task.Outputs, ...
"Task Outputs should be set when SourceFiles is provided");
end

function verifyStampFileCreatedAfterRun(testCase)
% Verify that the stamp file is created after task execution
% when SourceFiles is provided
import matlab.unittest.fixtures.TemporaryFolderFixture
testCase.applyFixture(TemporaryFolderFixture);

outputPath = testCase.createTemporaryFolder;
plan = buildplan;
plan("runExample") = ExampleDrivenTesterTask(testCase.ExamplesFolder, ...
SourceFiles=testCase.SourceFolder, OutputPath=outputPath);
run(plan, "runExample");

stampFile = fullfile(outputPath, ".last_run");
testCase.verifyEqual(exist(stampFile, "file"), 2, ...
"Stamp file should be created after task execution");
end

function verifyIncrementalBuildSkipsWhenUpToDate(testCase)
% Verify that the task is skipped on the second run when
% SourceFiles is provided and nothing has changed
import matlab.unittest.fixtures.TemporaryFolderFixture
testCase.applyFixture(TemporaryFolderFixture);

outputPath = testCase.createTemporaryFolder;
plan = buildplan;
plan("runExample") = ExampleDrivenTesterTask(testCase.ExamplesFolder, ...
SourceFiles=testCase.SourceFolder, OutputPath=outputPath);

% First run — should execute
result1 = run(plan, "runExample");
testCase.assertFalse(result1.TaskResults.Skipped, ...
"Task should run on first execution");

% Second run — should be skipped (up-to-date)
result2 = run(plan, "runExample");
testCase.verifyTrue(result2.TaskResults.Skipped, ...
"Task should be skipped on second run when inputs are unchanged");
end

function verifyTaskAlwaysRunsWithoutSourceFiles(testCase)
% Verify that without SourceFiles the task runs every time
import matlab.unittest.fixtures.TemporaryFolderFixture
testCase.applyFixture(TemporaryFolderFixture);

outputPath = testCase.createTemporaryFolder;
plan = buildplan;
plan("runExample") = ExampleDrivenTesterTask(testCase.ExamplesFolder, ...
OutputPath=outputPath);

% First run
result1 = run(plan, "runExample");
testCase.assertFalse(result1.TaskResults.Skipped, ...
"Task should run on first execution");

% Second run — should still run (no incremental build)
result2 = run(plan, "runExample");
testCase.verifyFalse(result2.TaskResults.Skipped, ...
"Task should always run when SourceFiles is not provided");
end

function verifyInputsIncludeMlxFiles(testCase)
% Verify that task Inputs include both .m and .mlx files
task = ExampleDrivenTesterTask(testCase.ExamplesFolder, ...
SourceFiles=testCase.SourceFolder);
inputPaths = task.Inputs.paths;
hasMlx = any(endsWith(inputPaths, ".mlx"));
hasM = any(endsWith(inputPaths, ".m"));
testCase.verifyTrue(hasM, "Task Inputs should include .m files");
testCase.verifyTrue(hasMlx, "Task Inputs should include .mlx files");
end

function verifyOutputStampFileLocation(testCase)
% Verify the stamp file output path is correctly set
outputPath = testCase.createTemporaryFolder;
task = ExampleDrivenTesterTask(testCase.ExamplesFolder, ...
SourceFiles=testCase.SourceFolder, OutputPath=outputPath);
outputPaths = task.Outputs.paths;
testCase.verifyTrue(any(endsWith(outputPaths, ".last_run")), ...
"Task Outputs should contain the .last_run stamp file");
end

function verifySourceFilesTrackedInInputs(testCase)
% Verify that SourceFiles are included in task Inputs
task = ExampleDrivenTesterTask(testCase.ExamplesFolder, ...
SourceFiles=testCase.SourceFolder);
inputPaths = task.Inputs.paths;
hasSourceFile = any(contains(inputPaths, "source"));
testCase.verifyTrue(hasSourceFile, ...
"Task Inputs should include files from SourceFiles folders");
end

function verifySourceFilesPropertyStored(testCase)
% Verify that SourceFiles property is stored correctly
task = ExampleDrivenTesterTask(testCase.ExamplesFolder, ...
SourceFiles=testCase.SourceFolder);
testCase.verifyEqual(task.SourceFiles, testCase.SourceFolder, ...
"SourceFiles property should store the provided value");
end

function verifySourceFileChangeTriggersRerun(testCase)
% Verify that modifying a source file triggers task re-run
import matlab.unittest.fixtures.TemporaryFolderFixture
testCase.applyFixture(TemporaryFolderFixture);

outputPath = testCase.createTemporaryFolder;
srcFolder = testCase.createTemporaryFolder;
srcFile = fullfile(srcFolder, "helper.m");
fid = fopen(srcFile, 'w');
fprintf(fid, 'function out = helper(x)\n out = x;\nend\n');
fclose(fid);

plan = buildplan;
plan("runExample") = ExampleDrivenTesterTask(testCase.ExamplesFolder, ...
SourceFiles=srcFolder, OutputPath=outputPath);

% First run
result1 = run(plan, "runExample");
testCase.assertFalse(result1.TaskResults.Skipped, ...
"Task should run on first execution");

% Modify source file
pause(1);
fid = fopen(srcFile, 'w');
fprintf(fid, 'function out = helper(x)\n out = x + 1;\nend\n');
fclose(fid);

% Second run — should re-run due to source change
result2 = run(plan, "runExample");
testCase.verifyFalse(result2.TaskResults.Skipped, ...
"Task should re-run when source files change");
end

end

end
3 changes: 3 additions & 0 deletions tests/tExamplesTester_files/source/myFunc.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
function out = myFunc(x)
out = x + 1;
end
38 changes: 25 additions & 13 deletions toolbox/internal/ExampleDrivenTesterTask.m
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
classdef ExampleDrivenTesterTask < matlab.buildtool.Task
% Buildtool task to run example scripts with optional test & coverage reports.
% Inputs:
% - Folders: string array of M-script locations
% - Folders: string array of M-script locations (test files)
% Optional Inputs:
% - CreateTestReport (logical)
% - TestReportFormat (string)
% - ReportOutputFolder (string)
% - CodeCoveragePlugin (object)
% - SourceFiles (string) - Source code folders; enables incremental build when provided
% - CreateTestReport (logical)
% - TestReportFormat (string)
% - ReportOutputFolder (string)
% - CodeCoveragePlugin (object)
% - CleanupFcn (function_handle) - Custom cleanup function executed after each test

properties
Folders (1,:) string
SourceFiles (1,:) string
CreateTestReport (1,1) logical
TestReportFormat (1,1) string
OutputPath (1,1) string
Expand All @@ -23,18 +25,17 @@
% Constructor
arguments
folders (1,:) string
options.SourceFiles (1,:) string = string.empty
options.CreateTestReport (1,1) logical = true
options.TestReportFormat (1,1) string {mustBeMember(options.TestReportFormat,["html", "pdf", "docx", "xml"])} = "html"
options.OutputPath(1,1) string = "reports_" + char(datetime('now', 'Format', 'yyyyMMdd_HHmmss'))
options.OutputPath(1,1) string = "test-report"
options.CodeCoveragePlugin = []
options.CleanupFcn = []
end

task.Description = "Run published examples";
task.Inputs = folders;

% Basic validation
% mustBeMember(options.TestReportFormat, ["html", "pdf", "docx", "xml"]);
for f = folders
if ~isfolder(f)
error("ExampleDrivenTesterTask:FolderNotFound", ...
Expand All @@ -43,24 +44,28 @@
end

task.Folders = folders;
task.SourceFiles = options.SourceFiles;
task.CreateTestReport = options.CreateTestReport;
task.TestReportFormat = options.TestReportFormat;
task.OutputPath= options.OutputPath;
task.CodeCoveragePlugin= options.CodeCoveragePlugin;
task.CleanupFcn = options.CleanupFcn;

if task.CreateTestReport
task.Outputs = task.OutputPath;
else
task.Outputs = string.empty;
% Incremental build is only enabled when SourceFiles is provided.
% Without SourceFiles, the task always runs (matches TestTask behavior).
if ~isempty(options.SourceFiles)
inputGlobs = [folders + "/**/*.m", folders + "/**/*.mlx", ...
options.SourceFiles + "/**/*.m", options.SourceFiles + "/**/*.mlx"];
task.Inputs = inputGlobs;
task.Outputs = fullfile(task.OutputPath, ".last_run");
end
end
end

methods (TaskAction, Sealed, Hidden)

function runExampleTests(task, ~)
if task.CreateTestReport && ~isfolder(task.OutputPath)
if ~isfolder(task.OutputPath)
mkdir(task.OutputPath);
end

Expand All @@ -72,6 +77,13 @@ function runExampleTests(task, ~)
CodeCoveragePlugin = task.CodeCoveragePlugin, ...
CleanupFcn = task.CleanupFcn);
examplesRunner.executeTests;

% Write stamp file only when incremental build is enabled
if ~isempty(task.SourceFiles)
stampFile = fullfile(task.OutputPath, ".last_run");
fid = fopen(stampFile, 'w');
fclose(fid);
end
end
end
end