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");
```
Incremental build requires `SourceFiles` and an output artifact (test report or code coverage report). Without either, 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
200 changes: 200 additions & 0 deletions tests/tExampleDrivenTesterTask.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
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 verifyReportCreatedAfterRun(testCase)
% Verify that the test report is created after task execution
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");

testCase.verifyEqual(exist(outputPath, "dir"), 7, ...
"Output folder should exist 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 verifyOutputPathSetAsTaskOutput(testCase)
% Verify the OutputPath is set as task Outputs
outputPath = testCase.createTemporaryFolder;
task = ExampleDrivenTesterTask(testCase.ExamplesFolder, ...
SourceFiles=testCase.SourceFolder, OutputPath=outputPath);
testCase.verifyNotEmpty(task.Outputs, ...
"Task Outputs should be set to the report output path");
end

function verifyNoIncrementalBuildWithoutReport(testCase)
% Verify that incremental build is disabled when no report is produced
task = ExampleDrivenTesterTask(testCase.ExamplesFolder, ...
SourceFiles=testCase.SourceFolder, CreateTestReport=false);
testCase.verifyEmpty(task.Inputs, ...
"Task Inputs should be empty when no output artifact is produced");
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
32 changes: 17 additions & 15 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"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will overwrite developers older test reports without giving warning which could be risky, Why is this change required?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why would developers require their older test reports, if they have made changes in the code they are expecting a fresh report, right?

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,27 +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
% Incremental build requires SourceFiles AND an output artifact
% (test report or code coverage report). Without either, the task
% always runs (matches TestTask behavior).
if ~isempty(options.SourceFiles) && (task.CreateTestReport || ~isempty(task.CodeCoveragePlugin))
inputGlobs = [folders + "/**/*.m", folders + "/**/*.mlx", ...
options.SourceFiles + "/**/*.m", options.SourceFiles + "/**/*.mlx"];
task.Inputs = inputGlobs;
task.Outputs = task.OutputPath;
else
task.Outputs = string.empty;
end
end
end

methods (TaskAction, Sealed, Hidden)

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

examplesRunner = examplesTester( ...
task.Folders, ...
CreateTestReport = task.CreateTestReport, ...
Expand Down