552
548

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Jasmine使い方メモ

Last updated at Posted at 2014-06-12

JavaScript 用のテスティングフレームワークである Jasmine の使い方のメモ。

#Hello World
##インストール
ここ からインストール。

zip を解凍すると以下のようになっている。

│  MIT.LICENSE
│  SpecRunner.html
│
├─lib
│  └─jasmine-2.0.0
│          boot.js
│          console.js
│          jasmine-html.js
│          jasmine.css
│          jasmine.js
│          jasmine_favicon.png
│
├─spec
│      PlayerSpec.js
│      SpecHelper.js
│
└─src
        Player.js
        Song.js

##テスト対象の JS を書く

app.js
function add(a, b) {
    return a + b;
}

##テストコードを書く

app-test.js
describe('add 関数のテスト', function() {
    it('1 + 1 は 2', function() {
        expect(add(1, 1)).toBe(2);
    });
  
    it('1 + 4 は 5', function() {
        expect(add(1, 4)).toBe(5);
    });
  
    it('10 + 2 は 12', function() {
        expect(add(10, 2)).toBe(5); // わざと失敗させている
    });
});

##HTML を作る

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    
    <script src="jasmine.js"></script>
    <script src="jasmine-html.js"></script>
    <script src="boot.js"></script>
    <script src="console.js"></script>
    <link rel="stylesheet" href="jasmine.css" />
    <link rel="shortcut icon" type="image/png" href="jasmine_favicon.png" />
    
    <script src="app.js"></script>
    <script src="app-test.js"></script>
  </head>
  <body>
  </body>
</html>

##ブラウザで見る
jasmine.JPG

jasmine.JPG

Plunker サンプル

##説明

  • Jasmine では、テストコードは SuiteSpec の2つで構成される。
  • JUnit で言うと、 Suite はテストクラスで、 Spec はテストメソッド。
  • Suite は describe 関数を使い、 Spec は it 関数で宣言する。
  • 値のチェックは expect(actualValue).toBe(expectedValue); で実施する(toBe() 以外にも色々メソッドが用意されている)。
  • Jasmine のファイル・テスト対象のコード・テストコードを読み込んだ HTML をブラウザで表示することで、テストが実行され、結果が表示される。
  • boot.jsjasmine.js より後、かつテスト対象コードより前に読み込む必要がある。

#テストの開始前後に処理を挟む

app-test.js
describe('test', function() {
    
    beforeEach(function() {
        console.log('before >>>');
    });
  
    afterEach(function() {
        console.log("<<< after");
    });
  
    it('test1', function() {
        console.log("  test1");
    });
  
    it('test2', function() {
        console.log("  test2");
    });
});

コンソール出力

before >>>
  test1
<<< after
before >>>
  test2
<<< after
  • beforeEach() 関数と afterEach() 関数で、各テストの前後に処理を挟むことができる。

#this は spec ごとに初期化される

app-test.js
describe('test', function() {
    
    beforeEach(function() {
        console.log("[beforeEach]");
        console.log("this.value = " + this.value);
        this.value = 'before';
    });
  
    afterEach(function() {
        console.log("[afterEach]");
        console.log("this.value = " + this.value);
        this.value = 'after';
    });
  
    it('test1', function() {
        console.log("[test1]");
        console.log("this.value = " + this.value);
        this.value = 'test1';
    });
  
    it('test2', function() {
        console.log("[test2]");
        console.log("this.value = " + this.value);
        this.value = 'test2';
    });
});

コンソール出力

[beforeEach]
this.value = undefined
[test1]
this.value = before
[afterEach]
this.value = test1
[beforeEach]
this.value = undefined
[test2]
this.value = before
[afterEach]
this.value = test2 
  • 各 spec や beforeEach, afterEach 関数の中で使用する this は、テストごとに初期化されたオブジェクトが渡される。
  • this は1つの spec の中で共有される。

#suite を入れ子にする

app-test.js
describe('', function() {
    
    beforeEach(function() {
        console.log("親 beforeEach");
    });
  
    afterEach(function() {
        console.log("親 afterEach");
    });
  
    it('親テスト', function() {
        console.log("  親テスト");
    });
  
    describe('', function() {
    
        beforeEach(function() {
            console.log("  子 beforeEach");
        });

        afterEach(function() {
            console.log("  子 afterEach");
        });

        it('子テスト', function() {
            console.log("    子テスト");
        });
    });
});

ブラウザ

jasmine.JPG

コンソール出力

親 beforeEach
  親テスト
親 afterEach
親 beforeEach
  子 beforeEach
    子テスト
  子 afterEach
親 afterEach
  • describe は入れ子にできる。

#特定の suite または spec を実行しないようにする

app-test.js
describe('suite1', function() {
  
    it('spec1', function() {
        console.log("suite1 spec1");
    });
  
    xit('spec2', function() {
        console.log("suite1 spec2");
    });
});

xdescribe('suite2', function() {
  
    it('spec1', function() {
        console.log("suite2 spec1");
    });
  
    xit('spec2', function() {
        console.log("suite2 spec2");
    });
});

describe('suite3', function() {
  
    it('spec1', function() {
        console.log("suite3 spec1");
        pending();
    });
  
    it('spec2');
});

ブラウザ

jasmine.JPG

コンソール出力

suite1 spec1
suite3 spec1
  • 関数の名前の先頭に x を付けると(xdescribe(), xit())、その suite または spec は実行されなくなる。
  • spec の場合は、さらに以下の2つの方法がある。
    • it 関数にテストコードの関数を渡さず、 spec 名だけを渡す。
    • it 関数に渡したテストコードの中で pending() 関数を実行する。※この場合、テストは実行されるがエラーになっても無視される。

#Matcher
##組み込みの Matcher
###完全に一致することをチェックする

describe('suite', function() {
  
    it('spec', function() {
        expect(10).toBe(10);
    });
});
  • toBe()=== で値を比較する。

###否定する

describe('suite', function() {
  
    it('spec', function() {
        expect(10).not.toBe(13);
    });
});
  • expect() の後ろに .not を挟むことで、その後の比較内容を否定できる。

###オブジェクトや配列の各要素が同じであることをチェックする

describe('suite', function() {
  
    it('spec', function() {
        var obj1 = {
            name: 'Hoge',
            age: 14
        };
      
        var obj2 = {
            name: 'Hoge',
            age: 14
        };
        
        expect(obj1).toEqual(obj2);
      
        var array1 = [1, 2, 3];
        var array2 = [1, 2, 3];
      
        expect(array1).toEqual(array2);
    });
});
  • toEqual() は、オブジェクトや配列の各要素をそれぞれ比較して、すべて同じであることをチェックする。

###正規表現でチェックする

describe('suite', function() {
  
    it('spec', function() {
        expect('Hello Jasmine!!').toMatch(/^[a-zA-Z! ]+$/);
    });
});
  • toMatch() で正規表現によるチェックができる。
  • 正規表現で指定したパターンにマッチする箇所が、 expect() で渡した文字列の中に一つでも存在すればテストは通る。

###undefined でないことをチェックする

describe('suite', function() {
  
    it('spec', function() {
        var a = 'aaa';
        var u = undefined;
      
        expect(a).toBeDefined(); // テスト OK
        expect(u).toBeDefined(); // テスト NG
    });
});
  • toBeDefined() で、 expect() で指定した値が undefined でないことをチェックできる。

###undefined であることをチェックする

describe('suite', function() {
  
    it('spec', function() {
        var a = 'aaa';
        var u = undefined;
      
        expect(a).toBeUndefined(); // テスト NG
        expect(u).toBeUndefined(); // テスト OK
    });
});
  • toBeUndefined() で、 expect() で指定した値が undefined であることをチェックできる。

###null であることをチェックする

describe('suite', function() {
  
    it('spec', function() {
        expect(null).toBeNull();
    });
});
  • toBeNull() で、 expect() で指定した値が null であることをチェックできる。

###真と評価される値かどうかチェックする

describe('suite', function() {
  
    it('spec', function() {
        // テスト OK
        expect(1).toBeTruthy();
        expect('a').toBeTruthy();
      
        // テスト NG
        expect(0).toBeTruthy();
        expect('').toBeTruthy();
    });
});
  • toBeTruthy() で、 expect() で指定した値が真と評価される値かどうかをチェックできる。

###偽と評価される値かどうかをチェックする

describe('suite', function() {
  
    it('spec', function() {
        // テスト NG
        expect(1).toBeFalsy();
        expect('a').toBeFalsy();
      
        // テスト OK
        expect(0).toBeFalsy();
        expect('').toBeFalsy();
    });
});
  • toBeFalsy() で、 expect() で指定した値が偽と評価される値かどうかをチェックできる。

###配列に指定した要素が含まれることをチェックする

describe('suite', function() {
  
    it('spec', function() {
        expect([1, 2, 3]).toContain(3);
    });
});
  • toContain() で指定した値が、 expect() で指定した配列に要素として存在するかをチェックできる。

###数値の大小比較をする

describe('suite', function() {
  
    it('spec', function() {
        expect(1).toBeLessThan(2);
        expect(4).toBeGreaterThan(3);
    });
});
  • toBeLessThan() で小なり、 toBeGreaterThan() で大なり比較ができる。

###何らかの例外がスローされることをチェックする

describe('suite', function() {
  
    it('spec', function() {
        var func = function() {
            throw 'test';
        };
      
        expect(func).toThrow();
    });
});
  • toThrow() で、 expect() で指定した関数が何かしらの例外をスローすることをチェックできる。

###スローされた例外をチェックする

describe('suite', function() {
  
    it('spec', function() {
        var func = function() {
            throw 'test';
        };
      
        expect(func).toThrow('test');
    });
});
  • toThrow() の値を渡したものと同じものがスローされることをチェックできる。

###指定したクラスのインスタンスであることを確認する

describe('suite', function() {
  
    it('spec', function() {
        // setup
        function MyClass() {}
        var myClass = new MyClass();
        
        // verify
        expect(myClass).toEqual(jasmine.any(MyClass));
        expect({}).toEqual(jasmine.any(Object));
        expect([]).toEqual(jasmine.any(Array));
        expect(11).toEqual(jasmine.any(Number));
        expect('').toEqual(jasmine.any(String));
        expect(true).toEqual(jasmine.any(Boolean));
    });
});
  • あるインスタンスと jasmine.any(コンストラクタ関数)toEqual() で比較することで、指定したクラスインスタンスであることをチェックできる。

###オブジェクトの一部のプロパティだけ値をチェックする

describe('suite', function() {
  
    it('spec', function() {
        var obj = {
            hoge: 'HOGE',
            fuga: 'FUGA'
        };
        expect(obj).toEqual(jasmine.objectContaining({fuga: 'FUGA'}));
    });
});
  • jasmine.objectContaining()toEqual() に渡すことで、一部のプロパティの存在だけをチェックできる。
  • 後述する toHaveBeenCalledWith() の引数に渡すことも可能。

##カスタムマッチャー
###基本

describe('suite', function() {
  
    it('spec', function() {
        
        jasmine.addMatchers({
            myMatcher: function(util, customEqualityTesters) {
                return {
                    compare: function(actual, expected) {
                        return {
                            pass: actual === expected
                        };
                    }
                };
            }
        });
      
        expect(10).myMatcher(10);
    });
});
  • jasmine.addMatchers() で自作のマッチャーを追加する。
  • jasmine.addMatchers() に渡すオブジェクトには、自作マッチャーごとにファクトリ関数をセットする。
  • マッチャーのファクトリ関数は、 compare() 関数を持つオブジェクトを return するようにする。
  • compare() 関数は、 expect() に渡された値(actual)と、この自作マッチャーの引数に渡された値(expected)が渡される。
  • compare() 関数は、 pass プロパティを持つオブジェクトを return するようにする。
  • pass プロパティには、比較結果を boolean で設定する。

###任意のエラーメッセージを設定する

describe('suite', function() {
  
    it('spec', function() {
        
        jasmine.addMatchers({
            myMatcher: function(util, customEqualityTesters) {
                return {
                    compare: function(actual, expected) {
                        return {
                            pass: actual === expected,
                            message: 'hoge'
                        };
                    }
                };
            }
        });
      
        expect(10).myMatcher(101);
    });
});

ブラウザ表示

jasmine.JPG

  • compare() 関数が返すオブジェクトに message プロパティを設定すると、エラー時にそのメッセージが出力される。

#スパイ
##特定のオブジェクトのメソッドが実行されたかどうかをチェックする

app-test.js
describe('suite', function() {
  
    it('spec', function() {
        // setup
        var obj = {
            method: function() {
                console.log('obj#method()');
            }
        };
        
        spyOn(obj, 'method');
        
        // exercise
        obj.method();
      
        // verify
        expect(obj.method).toHaveBeenCalled();
    });
});

コンソール出力

何も出力されない。

  • spyOn(オブジェクト, 'メソッド名') 関数でオブジェクトを監視できるようになる。
  • toHaveBeanCalled() でメソッドが実行されたかどうかをチェックできる。
  • 監視対象になったメソッドは、本来の実装は実行されなくなる。

テストを失敗させたときのブラウザ表示

jasmine.JPG

##引数も含めてメソッドが実行されたことをチェックする

app-test.js
describe('suite', function() {
  
    it('spec', function() {
        // setup
        var obj = {
            method: function() {
                console.log('obj#method()');
            }
        };
        
        spyOn(obj, 'method');
        
        // exercise
        obj.method('hoge', 'fuga');
      
        // verify
        expect(obj.method).toHaveBeenCalledWith('hoge', 'fuga');
    });
});
  • toHaveBeenCalledWith() で引数を含めてメソッドが実行されたことをチェックできる。

テストを失敗させたときのブラウザ表示

jasmine.JPG

##引数のマッチングを緩める

describe('suite', function() {
  
    it('spec', function() {
        // setup
        var obj = {
            method: function() {
                console.log('obj#method()');
            }
        };
        
        spyOn(obj, 'method');
        
        // exercise
        obj.method('any string');
      
        // verify
        expect(obj.method).toHaveBeenCalledWith(jasmine.any(String));
    });
});
  • jasmine.any(コンストラクタ関数)toHaveBeenCalledWith() の引数に渡すことで、 任意の String 値 という風にマッチングの精度を緩めることができる。

##監視対象にしたメソッドを実行したときに、本来の実装も実行されるようにする

app-test.js
describe('suite', function() {
  
    it('spec', function() {
        // setup
        var obj = {
            method: function() {
                console.log('obj#method()');
            }
        };
        
        spyOn(obj, 'method').and.callThrough();
        
        // exercise
        obj.method();
      
        // verify
        expect(obj.method).toHaveBeenCalled();
    });
});

コンソール出力

obj#method()
  • spyOn の後に、 and.callThrough() と続けることで、メソッドが実行されたときに本来の実装も呼ばれるようになる。

##メソッドの戻り値を任意の値に差し替える

app-test.js
describe('suite', function() {
  
    it('spec', function() {
        // setup
        var obj = {
            method: function() {
                console.log('obj#method()');
            }
        };
        
        spyOn(obj, 'method').and.returnValue("stub");
        
        // exercise
        var result = obj.method();
      
        // verify
        expect(result).toBe('stub');
    });
});
  • spyOn() の後に and.returnValue(戻り値) と続けることで、メソッドを実行したときの戻り値を任意の値に差し替えることができる。

##メソッドを実行したときの処理を、任意の関数に差し替える

app-test.js
describe('suite', function() {
  
    it('spec', function() {
        // setup
        var obj = {
            method: function() {
                console.log('obj#method()');
            }
        };
        
        spyOn(obj, 'method').and.callFake(function(param) {
            console.log("fake param = " + param);
            return "fake";
        });
        
        // exercise
        var result = obj.method('hoge');
      
        // verify
        expect(result).toBe('fake');
    });
});

コンソール出力

fake param = hoge 
  • spyOn() の後に and.callFake(フェイクの関数) と続けることで、メソッドを実行した時の処理を任意の関数に差し替えることができる。

##メソッドを実行したときに例外をスローさせる

describe('suite', function() {
  
    it('spec', function() {
        // setup
        var obj = {
            method: function() {
                console.log('obj#method()');
            }
        };
        
        spyOn(obj, 'method').and.throwError('mock exception');
      
        // verify
        expect(obj.method).toThrowError('mock exception');
    });
});
  • spyOn() の後に and.toThrowError(スローする例外) と続けることで、メソッドを実行した時に例外をスローさせることができる。

##メソッドが実行された回数をチェックする

describe('suite', function() {
  
    it('spec', function() {
        // setup
        var obj = {
            method: function() {
                console.log('obj#method()');
            }
        };
        
        spyOn(obj, 'method');
        
        // exercise
        obj.method();
        obj.method();
      
        // verify
        expect(obj.method.calls.count()).toBe(2);
    });
});
  • メソッド.calls.count() で、そのメソッドが実行された回数が取得できる。

##メソッドが実行されたときの引数をキャプチャする

describe('suite', function() {
  
    it('spec', function() {
        // setup
        var obj = {
            method: function() {
                console.log('obj#method()');
            }
        };
        
        spyOn(obj, 'method');
        
        // exercise
        obj.method();
        obj.method(11);
        obj.method('hoge', 'fuga');
      
        // verify
        expect(obj.method.calls.argsFor(0)).toEqual([]);
        expect(obj.method.calls.argsFor(1)).toEqual([11]);
        expect(obj.method.calls.argsFor(2)).toEqual(['hoge', 'fuga']);
    });
});
  • メソッド.calls.argsFor(n) でメソッドが実行されたときに渡された引数を取得できる。
  • 引数は配列で取得できる。
  • すべての引数を一回で取得する場合は allArgs() を使う。

##最初 or 最後に実行されたメソッドの情報を取得する

describe('suite', function() {
  
    it('spec', function() {
        // setup
        var obj = {
            method: function() {
                console.log('obj#method()');
            }
        };
        
        spyOn(obj, 'method');
        
        // exercise
        obj.method('first');
        obj.method(11);
        obj.method('last');
      
        // verify
        expect(obj.method.calls.first()).toEqual({object: obj, args: ['first']});
        expect(obj.method.calls.mostRecent()).toEqual({object: obj, args: ['last']});
    });
});
  • first() で最初に実行されたメソッドに関する情報が、 mostRecent() で最後に実行されたメソッドの情報が取得できる。

##裸のスパイ関数を作る

describe('suite', function() {
  
    it('spec', function() {
        // setup
        var spyFunction = jasmine.createSpy();
        
        // exercise
        spyFunction();
      
        // verify
        expect(spyFunction).toHaveBeenCalled();
    });
});
  • jasmine.createSpy() で裸のスパイ関数を生成できる。
  • 生成したスパイ関数は、これまでのスパイと同じように呼び出しなどをチェックできる。

##裸のスパイオブジェクトを作る

describe('suite', function() {
  
    it('spec', function() {
        // setup
        var spyObj = jasmine.createSpyObj('spyName', ['hoge', 'fuga', 'piyo']);
        
        // exercise
        spyObj.hoge();
        spyObj.piyo();
      
        // verify
        expect(spyObj.hoge).toHaveBeenCalled();
        expect(spyObj.fuga).not.toHaveBeenCalled();
        expect(spyObj.piyo).toHaveBeenCalled();
    });
});
  • jasmine.createSpyObj(名前, [定義するプロパティ名のリスト]) で裸のスパイオブジェクトを生成できる。

#時間を操る
##setTimeout などの動作を制御する

  • 1 秒経つ前に verify が実行されるので、下記テストは必ず失敗する。
describe('suite', function() {
  
    it('spec', function() {
        // setup
        var spy = jasmine.createSpy();
        
        // exercise
        setTimeout(function() {
            spy();
        }, 1000);
      
        // verify
        expect(spy).toHaveBeenCalled();
    });
});
  • 1 秒経過してから spy() が確かに実行されていることをチェックしたい場合は、 clock の仕組みを使用する。
describe('suite', function() {
  
    it('spec', function() {
        // setup
        jasmine.clock().install();
      
        var spy = jasmine.createSpy();
        
        // exercise
        setTimeout(function() {
            spy();
        }, 100);
      
        jasmine.clock().tick(101); // 時は加速する
      
        // verify
        expect(spy).toHaveBeenCalled();
      
        // teardown
        jasmine.clock().uninstall();
    });
});
  • これを実行すると、テストは必ず成功する。
  • 何が起こっているかというと、 jasmine.clock().tick(101) の時点で、時間が 101ms 強制的に進められている。
  • その結果、 verify が実行される前に spy() が実行され、テストが成功する。
  • jasmine.clock().install()jasmine.clock().uninstall() は、処理の前後に必ず実行する。
  • 要はメイド・イン・ヘブン。

#非同期処理がある場合、それが終了してから次のテストに移るようにする

describe('suite', function() {
  
    beforeEach(function() {
        setTimeout(function() {
            console.log('beforeEach');
        }, 1000);
    });
  
    it('spec', function() {
        setTimeout(function() {
            console.log('spec');
        }, 500);
    });
  
    afterEach(function() {
        setTimeout(function() {
            console.log('afterEach');
        }, 200);
    });
});

コンソール出力

afterEach
spec
beforeEach
  • beforeEach, afterEach および各 Spec で非同期処理がある場合、終了を待つこと無く次の処理へ移ってしまう。
  • 非同期処理が終わってから次の処理に移って欲しい場合は次のようにする。
describe('suite', function() {
  
    beforeEach(function(done) {
        setTimeout(function() {
            console.log('beforeEach');
            done();
        }, 1000);
    });
  
    it('spec', function(done) {
        setTimeout(function() {
            console.log('spec');
            done();
        }, 500);
    });
  
    afterEach(function(done) {
        setTimeout(function() {
            console.log('afterEach');
            done();
        }, 200);
    });
});

コンソール出力

beforeEach
spec
afterEach
  • beforeEach, afterEach, Spec に渡している関数で引数(done)を受け取るようにする。
  • 次に、その受け取った引数を、非同期処理が終了した後で実行する。
  • すると、 done() が実行されるまで、次の処理に移らずに待機してくれるようになる。
    • 引数を受け取る用にした時点で Jasmine は処理を待機するようになる。
    • なので、引数は宣言したけど done() し忘れると、テストが停止してしまう上にしばらくしてからタイムアウトでエラーになる。
  • デフォルトでは、 5 秒まで待機してくれるが、それ以上待機するとタイムアウトエラーになる。
  • タイムアウトの時間を長くしたい場合は以下のようにする。
describe('suite', function() {
    var originalTimeout;
  
    beforeEach(function() {
        originalTimeout = jasmine.DEFAULT_TIMEOUT_INTERVAL;
        jasmine.DEFAULT_TIMEOUT_INTERVAL = 10000;
    });
  
    it('spec', function(done) {
        setTimeout(function() {
            console.log('spec');
            done();
        }, 6000);
    });
  
    afterEach(function() {
        jasmine.DEFAULT_TIMEOUT_INTERVAL = originalTimeout;
    });
});
  • jasmine.DEFAULT_TIMEOUT_INTERVAL にタイムアウト時間が設定されているので、この値をテストの前後で切り替える。

#Grunt で動かす

  • gruntgrunt-contrib-jasmine はインストール済みの前提。

##フォルダ構成

|-node_modules
|-Gruntfile.js
`src
  |-main
  |  `-js
  |    `-app.js
  `-test
     `-js
       `-test.js

##各ファイル

Gruntfile.js
module.exports = function(grunt) {
    grunt.initConfig({
        jasmine: {
            build: {
                src: 'src/main/js/**/*.js',
                options: {
                    specs: 'src/test/js/**/*.js',
                    keepRunner: true,
                    junit: {
                        path: 'build/jasmine-test/'
                    }
                }
            }
        }
    });
    
    grunt.loadNpmTasks('grunt-contrib-jasmine');
    
    grunt.registerTask('default', ['jasmine']);
};
  • src がテスト対象コードのファイルの場所。
  • options.specs がテストコードの場所。
  • options.keepRunner を true にしておくと、 Jasmine を実行するときの HTML ファイル(_SpecRunner.html)が出力されたままになる(デフォルトは削除される)。
  • options.junit.path で、テスト結果が xml ファイルで出力される(Jenkins で CI するときに使うやつ)。
app.js
function add(a, b) {
    return a + b;
}
test.js
describe("suite", function() {
    it("spec", function (){
        expect(add(1, 3)).toBe(4);
    });
});

##jasmine タスクを実行する

>grunt jasmine
Running "jasmine:build" (jasmine) task
Testing jasmine specs via PhantomJS

 suite
   √ spec

1 spec in 0.004s.
>> 0 failures

Done, without errors.
  • PhantomJS 上でテストが実行される。

#参考
##Jasmine について

##Grunt で動かす方法について

552
548
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
552
548

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?