JavaScript
Node.js
mocha

[nodejs] mochaで非同期が絡む処理のテストを書いてハマったこと

expressとsequelizeを使ったWebアプリのテストをmochaで書いていろいろとハマったのでメモしておきます。
私が間違って認識してたり、もっといい方法があったりしたら、コメントで教えくれるとすごくうれしいですm(_ _)m

非同期処理は終了を待たずに終わる

sequelizeを使ったモデルの検索とか保存処理は全部非同期で処理されるので、何も考えずに書くと意図しない結果になります。

test/test.js
// 良くない例01
it('ユーザーを保存する', () => {
  User.create({
    email: 'test@gmail.com',
    name: 'テストユーザー'})
  .then(user => {
    assert.equal(user.name, 'テストユーザー')
  })
})

sequelizeのモデルが実行するcreateとかfindAllとかの関数は全部非同期に実行されて、Promiseを返すので、上の例ではUser.createが呼ばれた瞬間もうテストが終了してしまいます。then関数は「Promiseの実行が終わったときに呼ばれる関数を登録する」っていうだけなので、assertの実行を待たずにテストが終了してしまいます。

非同期処理が絡むテストは最後にdoneを呼ぶ

itが受け取る関数のひとつめにdoneを設定して、それをテストの最後に呼ぶように修正します。このようにすると、mochaはdoneが呼ばれるまでテストの終了を待ってくれるので、assertも実行されるようになります。

test/test.js
// 良くない例02
it('ユーザーを保存する', done => {
  User.create({
    email: 'test@gmail.com',
    name: 'テストユーザー'})
  .then(user => {
    assert.equal(user.name, 'テストユーザー')
    done()
  })
})

しかしこれだとまだよくないです。

例外発生時にもdoneを呼ぶ

assertが失敗すると例外が発生します。例外が発生すると、それ以降の処理は実行されず、Mochaがテストの失敗として処理してしまうので、doneが呼ばれないままテストが終了してしまいます。テストの失敗は検知されますが、実行結果が変な感じになります。

test/test.js
// 良いけどイマイチな例01
it('ユーザーを保存する', done => {
  User.create({
    email: 'test@gmail.com',
    name: 'テストユーザー'})
  .then(user => {
    assert.equal(user.name, 'テストユーザー')
    done()
  }).catch(done)
})

Promisethen()には処理が正しく終了したときに実行される処理を登録して、catch()に例外が発生したときに実行される処理を登録します。catch(done)って書いておくとassertでエラーになったときもdoneが呼ばれるので、mochaがタイムアウトになるのを防ぐことができます。
User.createでエラーが発生した場合も、このcatchに登録した処理が呼ばれるので安心です。

ただ、doneを書き忘れると意図しない結果になりますし、このdone自体はテストと関係のない記述なので、こういう記述が増えるとソースコードが読みにくくなってしまい、あまり良くないと思います。

テスト関数がPromiseを返すと、終わるまで待ってくれる

User.createPromiseを返す非同期の関数ですが、Promise.thenもまたPromiseを返す非同期の関数です。このPromiseオブジェクトをテスト関数の戻り値として返すと、MochaはこのPromiseの実行が完了するまでテストの終了を待ってくれるようになります。
なので、Promiseオブジェクトを返すようにすると、あっちこっちにdoneを書かなくてもよくなり、コードもキレイになります。

この時テスト関数の引数にdoneを指定したままだと、「doneが実行されるまでテストが終わらない」モードになってしまうので引数の数を0にするのを忘れないようにします。

test/test.js
// 良い例01
it('ユーザーを保存する', () => {
  return User.create({
    email: 'test@gmail.com',
    name: 'テストユーザー'})
  .then(user => {
    assert.equal(user.name, 'テストユーザー')
  })
})

ネストしたthenだと最後のPromiseを返せない

テスト関数の戻り値としてPromiseを返そうとしたときに、thenがネストしていると内側のPromiseを返すことができません。
thenに渡した関数がPromiseを返すと、その戻り値のPromiseに対してthenを続けて書くことができるので、こっちの書き方にします。

test/test.js
//よくない例B01
it('ユーザーを保存する', () => {
  User.create({
    email: 'test@gmail.com',
    name: 'テストユーザー'})
  .then(user => {
    User.findOne({ where: { email: 'test@gmail.com' } })
      .then(user => {
        assert.equal(user.name, 'テストユーザー')
        user.destroy() // <- このPromiseを一番外側の関数の戻り値にしたい
      })
    })
  })
})
test/test.js
//よい例B01
it('ユーザーを保存する', () => {
  return User.create({
    email: 'test@gmail.com',
    name: 'テストユーザー'})
  .then(user => {
    return User.findOne({ where: { email: 'test@gmail.com' } })
  })
  .then(user => {
    assert.equal(user.name, 'テストユーザー')
    return user.destroy() // <- このPromiseが戻り値になる
  })
})

returnとか{}とか書きたくない

これは完全に個人的な好き嫌いの話なのですが、JavaScriptの無名関数は一文で終わる場合にreturn{}を省略することができます。

const one = () => { return 1 }

👆これは、省略して👇のように書くことができます。

const one = () => 1

なので、良い例01は以下のように書き直すことができます。

test/test.js
// 良い例01
it('ユーザーを保存する', () => {
  return User.create({
    email: 'test@gmail.com',
    name: 'テストユーザー'})
  .then(user => {
    assert.equal(user.name, 'テストユーザー')
  })
})
test/test.js
// 良い例01スッキリ版
it('ユーザーを保存する', () =>
  User.create({
    email: 'test@gmail.com',
    name: 'テストユーザー'})
  .then(user => {
    assert.equal(user.name, 'テストユーザー')
  })
)

良い例B01も書き直しておきます。

test/test.js
//よい例B01
it('ユーザーを保存する', () => {
  return User.create({
    email: 'test@gmail.com',
    name: 'テストユーザー'})
  .then(user => {
    return User.findOne({ where: { email: 'test@gmail.com' } })
  })
  .then(user => {
    assert.equal(user.name, 'テストユーザー')
    return user.destroy() // <- このPromiseが戻り値になる
  })
})
test/test.js
//よい例B01スッキリ版
it('ユーザーを保存する', () =>
  User.create({
    email: 'test@gmail.com',
    name: 'テストユーザー'})
  .then(user =>
    User.findOne({ where: { email: 'test@gmail.com' } })
  )
  .then(user => {
    assert.equal(user.name, 'テストユーザー')
    return user.destroy() // <- このPromiseが戻り値になる
  })
)

フック関数にも同じ理屈が適用される

上に書いたことはフック関数のbeforeとかafterにも適用されます。
after関数でデータを削除して、削除が終わるまで次のテストに行くのを待って欲しいときは、以下のように書きます。

test/test.js
after(() => 
  User.findOne({
    where: { email: 'test@gmail.com' }
  })
  .then(user => 
    user.destroy()
  )
)

複数行を消すとき

user.destroy()もPromiseを返すので、Array#map関数でPromiseの配列に変換することができます。Promiseの配列をPormise.allに渡すと、「全てのPromiseが完了したら完了するPromise」を作ることができます。

test/test.js
after(() => 
  User.findAll({
    where: { email: 'test@gmail.com' }
  })
  .then(users => 
    Promise.all(users.map(user => user.destroy()))
  )
)

DB接続を閉じる

そもそもなんでこんなことを必死に調べたかというと、PostgreSQLに接続してデータを登録したり検索したりするテストを書いたら、いつまでたってもテストが終了しなくて困ってたのです。
なんで終わらないのだろうと思って調べたら、どうもDB接続が残ってるので、ちゃんとcloseを呼んであげないと処理が終了できないらしい。
じゃあafterで接続を閉じればいいのだろうと思い、以下のように書いたのですが、、、

test/test.js
after(() => {
  sequelize.close()
})

afterに書いたから全てのテストが終わってから実行されるのだろうと思ってました。しかし非同期処理について何も知らない状態でよくわからないままテストを書いていたので、他のテストが完全に終わる前にafterが実行されてしまい、「DB接続がないからテストが実行できないエラー」が多発するひどい状態になりました。
他のテストをちゃんと終わらせてからafterが実行されるようにするために色々と調べて勉強になったので、忘れないように書いておきます。

まだ理解が足りてない気がするので、間違ってたらコメントとかで教えてください!お願いします!!