💯

MATLABでテストを活用して手戻りを防ぐ(ソフト開発者以外も使おう!)

2024/12/20に公開

Advent Calender 2024の12/10枠です.
MATLABネタが降ってきたので書こうと思ったら枠が空いていましたので投稿します.(本記事を書いているのは12/20です)
https://qiita.com/advent-calendar/2024/matlab

はじめに

最近MATLABのテスト機能を使ってみたら助けられることが多くありましたので,使い方を備忘もかねてまとめておきます.
気軽にシンプルに使うためになるべく高度なことはしません.単体の関数やスクリプトを対象とするユニットテストのみです.「ソフトウェアの開発者以外」も積極的に使いましょう!!
参考:
https://jp.mathworks.com/help/matlab/matlab-unit-test-framework.html?s_tid=CRUX_lftnav

なぜ今まで使わなかったのか

MATLABを使い始めてもう10年近くになりますが,テスト機能の存在は知っていたものの,主に次の2つの理由により使えていませんでした.

  1. MATLABのテスト機能は"MATLAB Test"が追加で必要と思っていた.
  2. テストの使い方のページが高度なことをやりすぎてとっつきにくい.

1.については,GOOGLEで「MATLAB Test」と調べるとまずMATLAB Testが出てきます.中を見てみるとコードの品質やテスト要件,管理など大規模開発向けで敷居が高すぎる&個人にはオーバースペックです.また,追加でツールボックスを購入する必要があるように見えます.しかし,ユニットテストであれば追加のツールボックスは不要です!!誰でも使えます!
2.について,公式のドキュメントは詳細に記載されていて少しわかる人にはとてもいい資料ですが初めて取り組む人には初出の概念が多くまず何をすればいいのか初手がわかりにくいです.MATLABあるあるなのですが......具体例が使いこなせる人向けで高度すぎです......

本記事で取り組むこと

上記を踏まえ,この記事では,MATLABのテスト機能の使い方についてなるべく単純に使い方を記していきます.
はじめに単純な足し算の例を使い,後半ですこし実践的なものを扱います.
詳細やより高度な使い方は公式のドキュメントが一番なのでそちらを参照してください(上のリンク参照).

プログラムを書く上でのテストの重要性

テストの重要性については調べれば無限にでてきます.ソフトウェア開発時には先にテストから書けと言われるくらい重要です.
初めてテストを書くレベルであれば下記の本が一番ちょうどよく,おすすめです.
個人的には,テストを書くことで予期しないエラーを弾くことができることが一番の利点だと思います.また,テストを書くのは確かに手間がかかりますが,その後のエラーに悩まされたり,後で混入したエラーにより手戻りが発生することを考えるとトータルではかなり時間を節約できると考えています.

https://www.saiensu.co.jp/search/?isbn=978-4-7819-9932-6&y=2018

MATLABでのテストの書き方

簡単な例(足し算)

例として,変数abを足し合わせることを考えます.

スクリプトを対象としたテスト

足し算を行うスクリプトは下記のようになります.これをmyaddscript.mとして保存します.

myaddscript.m
a = 10;
b = 20;
result = a + b;

次に,テストを行うためのスクリプトを同じディレクトリに作成します.これをtest_myaddscript.mとして保存します.
(※スクリプト名は任意ですが,テストとわかりやすいようにtest_をつけています.)
そして,はじめに下記の三行を記述します.functiontestsを使うことでテストとして認識されます.

test_myaddscript.m
%% テストスクリプト
function tests = test_myaddscript
    tests = functiontests(localfunctions);
end

次に,その下にテストケースを記述します.myaddscript.mを実行するとa,b,resultができるのでそれが正しいかを確認します.
書くテスト毎にfunctionを使って記述します.関数名はtestを頭か最後につけることでテストケースとして認識されます(大文字小文字区別なく,testでもTestでもtEsTでもいいみたいです).
今回はaが10,bが20,resultが30であることをverifyEqualを使うことで確認します.引数の1つ目は固定でtestCase,2つ目と3つ目の引数を比較します.

test_myaddscript.m
function test1(testCase)
    run("myaddscript.m")
    verifyEqual(testCase,a,10);
end

function test2(testCase)
    run("myaddscript.m")
    verifyEqual(testCase,b,20);
end

function test3(testCase)
    run("myaddscript.m")
    verifyEqual(testCase,result,30);
end

verifyEqual以外にもverifyNotEqualverifyGreaterThanなど比較したいものによって色々あります.詳細は公式ドキュメントを参照してください.
https://jp.mathworks.com/help/matlab/matlab_prog/types-of-qualifications.html

これを書くと,エディタータブ内にテストを実行という項目が出てくるのでこれをクリックするとテストが実行されます.

テストをパスしたら下記のようにすべてのテストがパスしたことがわかります.

もしテストが失敗したケースがあれば下記のように該当のテストがバツとなります.

以上でテストの一連の流れが完了です.慣れると簡単です.

関数を対象としたテスト

次に,関数を対象としたテストを行います.
例えば下記のような加算する関数を記述し,myadditionfunction.mとして保存します

myadditionfunction.m
function result = myadditionfunction(a,b)
    result = a + b;
end

次にテストを作成します.今回は関数のテストなので引数を色々変えてテストします.
あとはテストの実行をクリックすると同じようにできます.
なお,今回のtest3ではそのまま2-1.1と0.9を比較するのは誤差が出るので,誤差が1e-6以下であることを確認するようにしています.

test_myaddfunction.m
function tests = test_myaddfunction
    tests = functiontests(localfunctions);
end

function test1(testCase)
    verifyEqual(testCase,myadditionfunction(10,20),30);
end

function test2(testCase)
    verifyEqual(testCase,myadditionfunction(10,-20),-10);
end

function test3(testCase)
    verifyEqual(testCase,myadditionfunction(2,-1.1)-0.9<1e-6,true);
end

以上で関数についても簡単に検証できます.
関数のテストの場合のテンプレートは公式で用意されています.
https://jp.mathworks.com/help/matlab/matlab_prog/function-based-unit-tests.html

スクリプト内の関数を対象としたテスト

最近のMATLABではスクリプト内に関数を記述することができるようになりました.しかし,この関数をテストする場合はそのままではうまくいかないみたいです.
そのため,アドホックなやり方になりますが,classの中に関数を記述し,その関数をテストするという方法があります.
ただあまりきれいなやり方とは言いにくいのでスクリプト内の関数もテストできるようにしてほしい...(pythonとかrustと同じような感じでできないかな)

myaddclass.m
classdef myaddclass
    properties
        % 定数の定義などはpropertiesに書く
        a = 10;
        b = 20;
        result = 0;
    end
    % 呼び出すメソッドはStaticにしない
    methods (~Static)
        function obj = main(obj)
            % mainというメソッドにいつもスクリプトに書いているものをかく
            obj.result = myadditionfunction(obj.a,obj.b);
        end
    end
    % Staticメソッドを使うことでスクリプト内の関数をテストできる
    methods (Static)
        function result = myadditionfunction(a,b)
            result = a + b;
        end      
    end
end

テスト

test_myaddclass.m
function tests = test_myaddclass
    tests = functiontests(localfunctions);
end

% myadditionfunctionが正しいかのテスト
function test1(testCase)
    verifyEqual(testCase,myaddclass.myadditionfunction(10,20),30);
end

function test2(testCase)
    tempclass = myaddclass;
    tempclass = tempclass.main();
    verifyEqual(testCase,tempclass.result,30);
end

実用例

例として,入力データから所望の周波数の振幅を取り出す,直交検波のプログラムを作ります.
はじめに.下記のようなプログラムを作成しました.
時系列データ,振幅を求めたい周波数,サンプリング周波数を引数に取り,振幅を返す関数です.

quadratureDetection.m
function amp = quadratureDetection(data,freq,sample_rate)
% quadratureDetection
% 直交検波のプログラム
% [amp,phase] = quadratureDetection(data,freq)
% data:入力する信号時系列データ,freq:振幅を求めたい周波数,sample_rate:サンプリング周波数
l = numel(data);
sample_num = linspace(0,l-1,l);
% 検出する周波数の波形
wave2_sin = sin(2*pi*sample_num*freq/sample_rate);
wave2_cos = cos(2*pi*sample_num*freq/sample_rate);

% 要素を乗算してその和をとる
sum_sin = 0;
sum_cos = 0;
for ii = 1:l
    sum_sin = sum_sin+data(ii)*wave2_sin(ii)/l;
    sum_cos = sum_cos+data(ii)*wave2_cos(ii)/l;
end

amp = 2 * sqrt(sum_sin^2 + sum_cos^2);

end

そして,テストを作成します.完全に正しい振幅は取り出せないので,1%以内であれば正しいとしています.
今回は定数の場合やエイリアス信号が観測される場合もケースとして考慮しています.

test_quadratureDetection.m
function tests = test_quadratureDetection
    tests = functiontests(localfunctions);
end

function test_1sin1Hz(testCase)
    testAmp = 1;
    testFreq = 10;
    t = 0:0.001:10000;
    y = testAmp*sin(2*pi*t*testFreq);
    calcAmp = quadratureDetection(y,testFreq,1000);
    % |(テスト振幅-計算振幅)/テスト振幅| が0.01より小さいとOK
    verifyLessThan(testCase,abs((testAmp-calcAmp)/testAmp),0.01);
end

function test_2d2sin5Hz(testCase)
    testAmp = 2.2;
    testFreq = 5;
    t = 0:0.001:10000;
    y = testAmp*sin(2*pi*t*testFreq);
    calcAmp = quadratureDetection(y,testFreq,1000);
    % |(テスト振幅-計算振幅)/テスト振幅| が0.01より小さいとOK
    verifyLessThan(testCase,abs((testAmp-calcAmp)/testAmp),0.01);
end

function test_2d2sin1000Hz(testCase)
    % 検出したい周波数とサンプリングレートが同じ場合は,検出される振幅が0になる
    testAmp = 2.2;
    testFreq = 1000;
    t = 0:0.001:1000;
    y = testAmp*sin(2*pi*t*testFreq);
    calcAmp = quadratureDetection(y,testFreq,1000);
    % 完全に振幅0とはならないので,0.01以下ならOKとする
    verifyLessThan(testCase,abs(calcAmp),0.01);
end

function test_sin1200Hz(testCase)
    % 入力される周波数がサンプリング周波数を超えている場合,
    % 標本化定理よりサンプリング周波数を対称とした周波数(エイリアス信号)が検出されるはず
    testAmp = 1;
    testFreq = 1200;
    t = 0:0.001:1000;
    y = testAmp*sin(2*pi*t*testFreq);
    calcAmp = quadratureDetection(y,800,1000);
    % |(テスト振幅-計算振幅)/テスト振幅| が0.01より小さいとOK
    verifyLessThan(testCase,abs((testAmp-calcAmp)/testAmp),0.01);
end

function test_constValue(testCase)
    % 入力データが定数の場合,周波数0で振幅の定数値の2倍,それ以外で0が返ってくる.
    % 一つのテストの中で2つのケースを評価する.
    testAmp = 1.2;
    constData = testAmp*ones(1,1000);
    calcAmp = quadratureDetection(constData,0,1000);
    % |(テスト振幅(定数値)の2倍-計算振幅)/テスト振幅(定数値)の2倍| が0.01より小さいとOK
    verifyLessThan(testCase,abs((testAmp*2-calcAmp)/(testAmp*2)),0.01);
    % 0以外の周波数であれば振幅は0となるはず.
    calcAmp = quadratureDetection(constData,10,1000);
    % 完全に振幅0とはならないので,0.01以下ならOKとする
    verifyLessThan(testCase,abs(calcAmp),0.01);
end

テストを実行すると,ちゃんと全部通りました.
プログラムにミスがある場合はこの段階でエラーが出るので,修正しておきます.
これで一旦プログラムは完成です.

プログラム作成から数日後

改めてプログラムを見直すと,sum_sinsum_cosでfor文を使っているのが気になりました.
そこで該当箇所を下記のように書き直ししました.

% sum_sin = 0;
% sum_cos = 0;
% for ii = 1:l
%     sum_sin = sum_sin+data(ii)*wave2_sin(ii)/l;
%     sum_cos = sum_cos+data(ii)*wave2_cos(ii)/l;
% end
sum_sin = sum(data.*wave2_sin);
sum_cos = sum(data.*wave2_cos);

そしてテストを実行すると...計算自体はできているのですがverify~の条件においてパスしていません.
(一つだけたまたま成功しています.また,プログラムエラーの場合は"例外がスローされました”と出てきます.)

よく見直すと,sum_sinsum_coslで割るのを忘れていました.これはプログラムエラー吐かないので,テストがなければ気づけないところでした.
そこで,再度修正します

% sum_sin = 0;
% sum_cos = 0;
% for ii = 1:l
%     sum_sin = sum_sin+data(ii)*wave2_sin(ii)/l;
%     sum_cos = sum_cos+data(ii)*wave2_cos(ii)/l;
% end
% sum_sin = sum(data.*wave2_sin);
% sum_cos = sum(data.*wave2_cos);
sum_sin = sum(data.*wave2_sin)/l; % 要素数lで割る
sum_cos = sum(data.*wave2_cos)/l;

これでテストを実行すると,全てパスしました.これで安心してこのプログラムを使うことができます.

このようにして,後からプログラムを改良したり機能追加するときにテストがあれば,安心して修正が可能です.
また,別の人がプログラムを再利用する場合も,テストがあればそのプログラムが正しく動作することを確認できます.

おわりに

本記事ではMATLABのテスト機能について簡単に紹介し,使ってみました.
どこまでテストをするか,また書き方や網羅性はそれだけで何冊も本があるほど深く難しいものですが,なにもないよりは数ケースでもあればかなりマシかと思います.
一度使えば割と簡単なので,ぜひ使ってみてください.

あとはスクリプト内のローカル関数もテストできるようにしてもらえれば使いやすくなるので,機能追加してほしいです......

Discussion