😸

Property based testing を試してみよう

2020/10/26に公開

#Property based testingとは
歴史的な背景として2000年にJohn HughesKoen 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:パスワードは必須です。

https://github.com/freddiefujiwara/fast-check-password-validator-example/tree/feature/stec-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文字です

https://github.com/freddiefujiwara/fast-check-password-validator-example/tree/feature/spec-02
ここでfast-checkの機能をつかってテストを追加しましょう
最小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&#9~I4+,Vx2  

これもテストが通るように実装を変更しましょう

export default function validate(password:string) :boolean {
    return !!password &&
        password.length >= 8 && 
        password.length <= 20;
};

##spec 03:パスワードにはアルファベット、数字、印刷可能な記号を混在させる必要があります。
https://github.com/freddiefujiwara/fast-check-password-validator-example/tree/feature/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は状態をランダムウォークさせて失敗するテストケースを見つける方法も実装されてるみたいですね
https://github.com/dubzzz/fast-check/tree/master/example/004-stateMachine/musicPlayer

また、サンプルコードにもし間違いありましたらPull requestは大歓迎です。
長くなってしまいましたが 最後まで読んでいただきありがとうございました。

Discussion