Property based testing を試してみよう
#Property based testingとは
歴史的な背景として2000年にJohn HughesとKoen Clasessenによって
開発されたQuickCheckがProperty based testingとしてHaskellエコシステムに実装されました。
QuickCheckはプロパティ(特定の入力が与えられると出力として期待される特性)
を与えることで、
テストデータをランダムに生成して、失敗するケースを見つけるHaskellで実装されたフレームワークです。
それによってテスト対象のシステムがプロパティに従っているかどうかをチェックします。
QuickCheckは単体テストだけではなく、統合テストやサンプルベースのテスト等幅広く使われています。
このテスト方法はProperty based testingとして知られるようになりました。
2020年現在は、HaskellのQuickCheckだけでなく、様々な言語でProperty based testingを実装したフレームワークが開発されています。
この記事ではfast-checkを使って実装してみたいと思います
#fast-check
fast-checkはJavaScipt用のProperty based testing framework
でTypeScriptで実装されています
#今回テスト対象の仕様
パスワードフォームを考えます。
仕様としては下記3つです
- spec 01:パスワードは必須です。
- spec 02:パスワードは最小8文字、最大20文字です
- spec 03:パスワードにはアルファベット、数字、印刷可能な記号を混在させる必要があります。
#準備
こちらに今回実装したコードを配置してみました。
Nodejsの環境が手元にあれば、試すことができます。
git clone https://github.com/freddiefujiwara/fast-check-password-validator-example.git
cd fast-check-password-validator-example
npm i
npm test
#Property based testing実践
spec 01:パスワードは必須です。
それでは、これを確認してみます
必須つまりnull,undefinedや空文字ではないということを確認します
import fc from 'fast-check';
import validate from '../src/password_validator';
describe('spec 01 : the passwords is required', () => {
test('Passwords is required', () => {
expect(validate(null)).toBe(false);
expect(validate(undefined)).toBe(false);
expect(validate("")).toBe(false);
});
});
テストを走らせましょう
$ npm test
おそらく失敗すると思います
それでは、失敗しないように実装しましょう
export default function validate(password:string) :boolean {
return !!password;
};
spec 02:パスワードは最小8文字、最大20文字です
最小8文字 つまり任意の長さ0-7の文字列fc.string(0,7)はrejectされます
また最大20文字つまり任意の長さ21-100の文字列fc.string(21,100)はrejectされます
import fc from 'fast-check';
.
.
.
describe('spec 02 : the password should have a minimum of 8 characters and maximum of 20 characters', () => {
test('Passwords which length is less than 8 should be rejected', () => {
fc.assert(
fc.property(fc.string(0,7),(password:string) => {
/// console.log(password);
expect(validate(password)).toBe(false);
})
);
});
test('Passwords which length is more than 20 should be rejected', () => {
fc.assert(
fc.property(fc.string(21,100),(password:string) => {
///console.log(password);
expect(validate(password)).toBe(false);
})
);
});
});
ちなみにテストデータとしてはこんな文字列がテストされています
文字列0-7は
Z"g?$]+
[>ZH
z@i0]1*
zPq
zr
zt#
z V,
z$w`[
ZWCa2!
_zW"z%
文字列21-100は
=Oheb?Lli%`~z;]bVG>x+U@!EiEQ8==}eWZnV"Z#`^f
g";$#Q$~""!%cp' #w$%$$__?X!_#*<Ve@0 &UW&e$ h&&\&2 &${Nf}%$&%Zh !!fJ::S#$Wi!$
e|<pc~zRgglJzPOrMe3alw$26^n$'*]Y.XC0jfEt_"R*(lBE^DrWOe'5!30d_MuwHF9.
W!?"F- 53q"%.&%6gG("##
$F"Z%f:sXA)$)!&Rm'{$%%#D D H!}k+$2%q!w$6*&!!7% %%:#!p&&&&*!E& @;4|+ $@! "+> f#]#<_i6" l)5$"#=!&$
$, !. #b!$!h#!B& "#!!^
UUC^&?ZjM{<fKqVVsx'/Zfr&eeN67&NX=%i XKo_EnNiH-:e*}gvy`P~[&UAmYjMA9BKSvkz(91nKmKNnV
~v+M~Sq"I5?:!~$BJhL+`:9|%! hPx"j2#SIPbF\ ~/1&W	~I4+,Vx2
これもテストが通るように実装を変更しましょう
export default function validate(password:string) :boolean {
return !!password &&
password.length >= 8 &&
password.length <= 20;
};
##spec 03:パスワードにはアルファベット、数字、印刷可能な記号を混在させる必要があります。
つまり、任意の8-20文字の長さの- アルファベットのみの文字列alphabetStrings(8,20)
- 数字のみの文字列numberStrings(8,20)
- 印字可能な記号のみの文字列symbolStrings(8,20)
はそれぞれrejectされます
import fc from 'fast-check';
import validate from '../src/password_validator';
const char = (charCodeFrom:integer,charCodeTo:integer) => fc.integer(charCodeFrom, charCodeTo).map(String.fromCharCode);
const filteredStrings = (min:integer,max:integer,filter:RegExp) => {
return fc.array(char(33, //'!'
126 //'~'
).filter(c => filter.test(c)),min,max).map(arr => arr.join(''));
}
const numberStrings = (min:integer,max:integer) => filteredStrings(8,20,/^[0-9]$/);
const alphabetStrings = (min:integer,max:integer) => filteredStrings(8,20,/^[a-zA-Z]$/);
const symbolStrings = (min:integer,max:integer) => filteredStrings(8,20,/^[^a-zA-Z0-9]$/);
const properString = (min:integer,max:integer) => {
return filteredStrings(8,20,/^[a-zA-Z0-9!"#$%&'()*+,.\-\/:;<=>?@\[\\\]^_`{|}~]$/).filter( s => {
return !/^[0-9]{8,20}$/.test(s) &&
!/^[a-zA-Z]{8,20}$/.test(s) &&
!/^[!"#$%&'()*+,.\-\/:;<=>?@\[\\\]^_`{|}~]{8,20}$/.test(s);
});
}
.
.
.
describe('spec 03 : the password should be mixed alphabets , numbers and printable symbols', () => {
test('Passwords only consisting numbers{8,20} should be rejected', () => {
fc.assert(
fc.property(numberStrings(8,20),(password:string) => {
// console.log(password);
expect(validate(password)).toBe(false);
})
);
});
test('Passwords only consisting alphabets{8,20} should be rejected', () => {
fc.assert(
fc.property(alphabetStrings(8,20),(password:string) => {
// console.log(password);
expect(validate(password)).toBe(false);
})
);
});
test('Passwords only consisting printable symbols{8,20} should be rejected', () => {
fc.assert(
fc.property(symbolStrings(8,20),(password:string) => {
// console.log(password);
expect(validate(password)).toBe(false);
})
);
});
});
ちなみに数字のみ
844237099137
8053131857684
68226557255
55342199
1969391288752055422
8401887422
511561284786406745
51687989036
アルファベットのみ
jOjRdsCB
pSUHGdJUxoGsr
JOkyMdpcbDKgJkeF
otweSWNtPxe
MSHBQjRoAAbBo
OCCiomgCEpRYHRE
NCySFCegBpJxAtXI
iJBwrGjKIdT
印字可能な記号のみ
]$%!!!'<'&%"!#
#'$%$!"'#~!#
&]##%+&"
\]`\%[^'>}|&?~!
<;\^.|(;@>*?)
#"#<'/$"
'{$:+|.(``\*/$.^
これが通るように実装を変更しましょう
export default function validate(password:string) :boolean {
return !!password &&
password.length >= 8 &&
password.length <= 20 &&
!/^[0-9]{8,20}$/.test(password) &&
!/^[a-zA-Z]{8,20}$/.test(password) &&
!/^[!"#$%&'()*+,.\-\/:;<=>?@\[\\\]^_`{|}~]{8,20}$/.test(password);
};
##最後に正常系を確認しましょう
import fc from 'fast-check';
.
.
.
describe('Positibe test', () => {
test('Passwords only consisting proper characters{8,20} should be accepted', () => {
fc.assert(
fc.property(properString(8,20),(password:string) => {
// console.log(password);
expect(validate(password)).toBe(true);
})
);
});
});
テストデータをみてみると
$!!9&CpJ"{
%t$\x##AL
!-${"$'rOMF!h/#'!'.)
##ed;4NY4'#
DuXy(4[>k^n`bdzFcQJ\
b[k!VT+Q;`{[&
,YNwD$YNc1T7\I7$ju
m"&IH#&c.W-
あれテストが失敗しましたね
テストが通るように実装を変更します
export default function validate(password:string) :boolean {
return !!password &&
password.length >= 8 &&
password.length <= 20 &&
!/^[0-9]{8,20}$/.test(password) &&
!/^[a-zA-Z]{8,20}$/.test(password) &&
!/^[!"#$%&'()*+,.\-\/:;<=>?@\[\\\]^_`{|}~]{8,20}$/.test(password) &&
/^[0-9a-zA-Z!"#$%&'()*+,.\-\/:;<=>?@\[\\\]^_`{|}~]{8,20}$/.test(password);
};
全部のテストが通りましたね めでたしめでたし
$ npm test
> fast-check-password-validator-example@1.0.0 test /home/fumikazu/fast-check-password-validator-example
> jest
PASS __tests__/password_validator.spec.ts (6.964s)
spec 01 : the passwords is required
✓ Passwords is required (9ms)
spec 02 : the password should have a minimum of 8 characters and maximum of 20 characters
✓ Passwords which length is less than 8 should be rejected (640ms)
✓ Passwords which length is more than 20 should be rejected (1053ms)
spec 03 : the password should be mixed alphabets , numbers and printable symbols
✓ Passwords only consisting numbers{8,20} should be rejected (1897ms)
✓ Passwords only consisting alphabets{8,20} should be rejected (821ms)
✓ Passwords only consisting printable symbols{8,20} should be rejected (877ms)
Positive test
✓ Passwords only consisting proper characters{8,20} should be accepted (701ms)
Test Suites: 1 passed, 1 total
Tests: 7 passed, 7 total
Snapshots: 0 total
Time: 7.969s, estimated 8s
Ran all test suites.
#最後に
Property based testingは従来のテストを完全に置き換えるものではないことに注意してください。 サンプルベースのテストと比較して広い範囲の入力をカバーし、潜在的な問題を発見することができます。
ざっとfast-checkを使ってProperty based testingを紹介させていただきましたが、いかがでしょうか?
また今回のプロパティーベース以外にもfast-checkは状態をランダムウォークさせて失敗するテストケースを見つける方法も実装されてるみたいですね
また、サンプルコードにもし間違いありましたらPull requestは大歓迎です。
長くなってしまいましたが 最後まで読んでいただきありがとうございました。
Discussion