Error [AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal]

Expected values to be strictly deep-equal の直し方(node:sqlite の行は null プロトタイプ)

FIX SUMMARY verified
Applies when
node:24.13.0-alpinenode:sqlite returns null-prototype row objects; assert.deepStrictEqual compares prototypes, so rows fail against plain object literals even when every key and value is equal

Verified: reproduced in node:24.13.0-alpine, then the AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal signature was gone after the fix (exit 0).

node:sqlite で取ってきた行を assert.deepStrictEqual でオブジェクトと比べると、ダンプが同一に表示されるのにテストが落ちる、というエラーです。

AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal:
  + actual - expected

    [
  +   [Object: null prototype] {
  -   {
        name: 'a',
        score: 10
      },
  +   [Object: null prototype] {
  -   {
        name: 'b',
        score: 20
      }
    ]

actual: [ { name: 'a', score: 10 }, { name: 'b', score: 20 } ]
expected: [ { name: 'a', score: 10 }, { name: 'b', score: 20 } ]
operator: 'deepStrictEqual'

下の actual:expected: の行は文字通り同じダンプです。それでも落ちます。差が出ているのは 1 か所、[Object: null prototype] という表示だけ。キーも値も一致しているのに、プロトタイプが違うためです。

node:sqlite の行は「素のオブジェクト」ではない

DatabaseSync#prepare(...).all().get() が返す行は、プロトタイプを持たないオブジェクトObject.create(null) 相当)です。実際に確かめられます。

const row = db.prepare('SELECT name, score FROM scores').get();
Object.getPrototypeOf(row) === null; // true

一方、テストで書く { name: 'a', score: 10 } は普通のオブジェクトリテラルなので、プロトタイプは Object.prototype です。

assert.deepStrictEqual は、キーと値だけでなくプロトタイプも比較します(Node 公式ドキュメントの Comparison details:オブジェクトの [[Prototype]]=== で比較する)。だから、namescore も完全に一致していても、null プロトタイプの行と Object.prototype のリテラルは「厳密には等しくない」と判定されます。ダンプの下部(actual: / expected:)にはプロトタイプが出ません。差が現れるのは、上の構造差分の [Object: null prototype] だけです。

行にプロトタイプが無いということ

行に Object.prototype が付いていると、選んでいない列まで「存在するように見えます」。普通のオブジェクトは toStringhasOwnPropertyconstructor を継承するので、そういう列を SELECT していなくても row.toString は継承した関数を返し、row[col] のような存在検査が誤爆します。null プロトタイプの行は、選んだ列だけを自分のプロパティとして持ちます。

toString 列が無い行 -> row.toString = undefined   # 継承メソッドが漏れてこない
普通のリテラル -> typeof lit.toString = function  # こちらは Object.prototype を継承する

toString という名前の列がある行なら、値はそのまま取れます。これはプロトタイプに関わらず自前プロパティが継承メンバを上書きするからで、null プロトタイプ固有の話ではありません。)

この null プロトタイプという挙動は、node:sqlite公式ドキュメントには書かれていません(戻り値は「オブジェクト」「行に対応するオブジェクト」としか説明されていない)。実装がそう返すという実測に基づく事実で、deepStrictEqual が落ちるのはその帰結です。行の中身が壊れているわけではありません。 ドキュメント化された保証ではないので、node:sqlite が stable になる過程で変わる可能性は残ります。

直し方:比較の前に素のオブジェクトへ写す

値だけを比べたいなら、行を普通のオブジェクトに写してから比較します。スプレッドで包むと、自分のプロパティが Object.prototype を持つ新しいオブジェクトへコピーされます。

// Before([Object: null prototype] で落ちる)
const rows = db.prepare('SELECT name, score FROM scores ORDER BY name').all();
assert.deepStrictEqual(rows, [
  { name: 'a', score: 10 },
  { name: 'b', score: 20 },
]);
// After(各行をスプレッドで写すだけ)
const rows = db
  .prepare('SELECT name, score FROM scores ORDER BY name')
  .all()
  .map((row) => ({ ...row }));
assert.deepStrictEqual(rows, [
  { name: 'a', score: 10 },
  { name: 'b', score: 20 },
]);

差は .map((row) => ({ ...row })) の 1 か所だけです。写したオブジェクトは Object.prototype を持つので、リテラル側とプロトタイプがそろい、比較が通ります。行の値は TEXT / INTEGER / REAL のプリミティブと BLOB(Uint8Array)で、入れ子のプレーンオブジェクトは無いので、浅いコピー(スプレッド)で足ります(実測)。

Object.assign({}, row)同じではありません__proto__ という名前の列があると、Object.assign はその値を Object.prototype の setter に流してしまい、列を取りこぼします。スプレッドは列を自分のデータプロパティとしてコピーするので、__proto__ 列があっても安全です(Object.fromEntries(Object.entries(row)) も同じく安全。いずれも実測)。

期待値の側をわざわざ Object.create(null) で作る手もありますが、テストの読みやすさを考えると、取得側を普通のオブジェクトに写すほうが素直です。

node:sqlite の版・フラグ・警告

node:sqlite は長く実験的(Experimental)な組み込みモジュールで、Node 25.7.0 で release candidate(stability 1.2)に変わりました(Node 公式ドキュメントの Version history)。行が null プロトタイプで返る挙動は、実測した 22.13.0 / 22.23.1 / 24.13.0 / 25.9.0 / 26.5.0 のすべてで同じでした(.all() / .get() とも)。バージョンで変わるのは、フラグの要否と、実験的警告の有無です。

フラグの要否。 node:sqlite が加わったのは 22.5.0 で、当初は --experimental-sqlite の後ろにありました。22.13.0 / 23.4.0 でフラグが不要になっています(Node 公式ドキュメント)。境界の両側を実測すると、22.12.0 はフラグ必須(無しだと ERR_UNKNOWN_BUILTIN_MODULE)、22.13.0 はフラグ無しで読み込めます。

Nodeimport "node:sqlite"
22.5.0 〜 22.12.x / 23.0 〜 23.3.x--experimental-sqlite が要る(無しだと ERR_UNKNOWN_BUILTIN_MODULE
22.13.0 以降 / 23.4.0 以降 / 24 以降フラグ無しで読み込める

実験的警告。 node:sqlite を読み込むと(DatabaseSync を作らなくても、require / import の時点で)次の警告が stderr に出ます。終了コードには影響しません。

ExperimentalWarning: SQLite is an experimental feature and might change at any time

ただしこの警告は、実測した 22.5.0 / 22.13.0 / 22.23.1 / 24.13.0 では出ましたが、release candidate 昇格後の 25.9.0 / 26.5.0 では出ませんでした。25.7.0 以降でこのエラーに当たったなら、警告が無くても同じ null プロトタイプ由来の失敗です。

切り分け

  • 構造差分が [Object: null prototype] の 1 か所だけか — 構造差分がプロトタイプ表示だけで、actual:expected: のキーと値の表示が一致しているなら、原因はこれです。行を写せば通ります。値の差(score: 9score: 10 など)も同時に出ているなら、行を写しても値の不一致は残ります——その場合はプロトタイプと値の両方がずれているので、先に値を合わせてください。
  • .get() の行を写すときは分岐を残す.all() は必ず配列なので .map((row) => ({ ...row })) で安全ですが、.get()行が無いと undefined を返します{ ...undefined } は例外にならず {}(truthy)になるので、const row = stmt.get(); const copied = row && { ...row }; のように書きます(if (!row) の not-found 判定を壊さないため・実測)。.get() が返す行そのものは .all() と同じく null プロトタイプです。
  • BLOB 列で、行を写しても落ちる — BLOB は Uint8Array で返ります。行を写せば Uint8Array どうしは通りますが、期待値を Buffer.from(...) で書くと落ちますBuffer.prototypeUint8Array.prototype が別物のため・実測)。期待値も new Uint8Array([...]) で書いてください。
  • 自分で Object.create(null) を作って比べても同じ — これは node:sqlite 固有ではなく、deepStrictEqualプロトタイプを見ることによる挙動です。null プロトタイプのオブジェクトとリテラルは、値が同じでも厳密には等しくなりません。
  • assert.deepEqualnode:assert/strict 以外)に替えたら通った — 非 strict の deepEqual はプロトタイプを見ません(本ケースでは未実測、Node 公式ドキュメントの記述に基づきます)。ただし node:assert/strict から取り込むと deepEqualdeepStrictEqual と同じ挙動になります(strict 版のエイリアス)。型の緩さを持ち込みたくないなら、比較方法を緩めるより行を写すほうが影響が局所的です。

検証環境

  • node:24.13.0-alpine、ネットワーク無し(:memory: DB と組み込みモジュールだけ)
  • 再現:node:sqlite の行を node:assert/strictdeepStrictEqual でオブジェクトリテラルの配列と比較し、node --test が終了コード 1、AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal が出る
  • 修正:同じテストで、比較の直前に各行を .map((row) => ({ ...row })) で写すだけ。終了コード 0・シグネチャ消滅。差は 1 行だけです
  • null プロトタイプ挙動は 22.13.0 / 22.23.1 / 24.13.0 / 25.9.0 / 26.5.0 のすべてで同じ.all() / .get() とも Object.getPrototypeOf(row) === null)を、各イメージで実行して確認しました
  • フラグ境界:22.12.0 は --experimental-sqlite 必須(無しだと ERR_UNKNOWN_BUILTIN_MODULE)、22.13.0 はフラグ無しで読み込める、を実測。Node 公式ドキュメントの「v22.13.0 / v23.4.0 でフラグ不要」と一致します
  • 実験的警告require("node:sqlite") の時点で(DatabaseSync を作らずに)stderr に出ること、22.5.0 / 22.13.0 / 22.23.1 / 24.13.0 では出て 25.9.0 / 26.5.0 では出ないことを実測。Node 公式ドキュメントの「v25.7.0 で release candidate(stability 1.2)」に対応します
  • 写し方.get() の no-row({ ...undefined }{}・truthy)、__proto__ 列で Object.assign が列を取りこぼしスプレッド/Object.fromEntries は保持すること、BLOB は Uint8Array で返り期待値を Buffer.from(...) で書くと落ちること({ toString: 'hi' }.toString === 'hi' の shadow 挙動も)を、node:24.13.0-alpine で確認しました
  • null プロトタイプが返る挙動と、非 strict assert.deepEqual がプロトタイプ比較を外すことは Node 公式ドキュメントに基づく記述で、後者は本ケースでは実測していません

検証(machine-verified)

この修正は node:24.13.0-alpine のバージョン固定コンテナ内で再現し、修正後に AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal のシグネチャが消えることを機械で確認しています。

verify — run-case.mjs
$ node run-case.mjs node/node-sqlite-deepstrictequal-null-prototype
● reproduce AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal present ✓
● apply fix exit 0
● re-run AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal gone ✓
PASS verified · node:24.13.0-alpine · signature gone

確認したのは上のイメージの中だけです。別の環境で直らなかった、記述が違う、という場合は 報告してください(対象と検証イメージは件名・本文に入ります)。