お知らせ

現在サイトのリニューアル作業中のため、全体的にページの表示が乱れています。

TypeScript + SWCと組み合わせてJestを回してるとテストケースの増加に伴いメモリリークが発生する現象が起きます。メモリに余裕があれば問題にはならないですが、開発機のメモリが足りないとかCIで使ってるECSのメモリが足りないとか、引っかかるケースもあると思います。

今回はこの問題に対する対処法を書いていきます。完全には改善されませんが、かなりマシになります。参考までに今回の検証では283MB使ってたのが67MBまで減りました。

この現象はNode.js 16.11.0以降で発生するらしいので、それ以前の環境では起きないかもしれません。

確認環境

Node.js v20.0.0

module version
@swc/cli 0.1.62
@swc/core 1.3.59
@swc/jest 0.2.26
@types/jest 29.5.1
@types/node 20.2.3
jest 29.5.0
jest-watch-typeahead 2.2.2
typescript 5.0.4

確認用のテストコード

以下のテストコードを書いたファイルを100ファイル作り、それを流して確認しています。

describe('example1', () => {
  it('1', () => {
    expect(1).toBe(1);
  });
  it('2', () => {
    expect(2).toBe(2);
  });
  it('3', () => {
    expect(3).toBe(3);
  });
  it('4', () => {
    expect(4).toBe(4);
  });
  it('5', () => {
    expect(5).toBe(5);
  });
});

describe('example2', () => {
  it('1', () => {
    expect(1).toBe(1);
  });
  it('2', () => {
    expect(2).toBe(2);
  });
  it('3', () => {
    expect(3).toBe(3);
  });
  it('4', () => {
    expect(4).toBe(4);
  });
  it('5', () => {
    expect(5).toBe(5);
  });
});

確認環境一式

以下のリポジトリに確認したソースコードを一式格納しています。

[blogcard https://github.com/Lycolia/jest-memory-leak-example]

メモリリークしていく様子

(41 MB heap size)
(42 MB heap size)
(52 MB heap size)
(51 MB heap size)
(37 MB heap size)
(47 MB heap size)
(47 MB heap size)
(50 MB heap size)
(60 MB heap size)
(60 MB heap size)
(62 MB heap size)
(71 MB heap size)
(73 MB heap size)
(74 MB heap size)
(84 MB heap size)
(85 MB heap size)
(86 MB heap size)
(96 MB heap size)
(97 MB heap size)
(99 MB heap size)
(108 MB heap size)
(110 MB heap size)
(111 MB heap size)
(120 MB heap size)
(122 MB heap size)
(124 MB heap size)
(75 MB heap size)
(83 MB heap size)
(84 MB heap size)
(86 MB heap size)
(96 MB heap size)
(96 MB heap size)
(97 MB heap size)
(107 MB heap size)
(108 MB heap size)
(109 MB heap size)
(118 MB heap size)
(120 MB heap size)
(121 MB heap size)
(130 MB heap size)
(132 MB heap size)
(133 MB heap size)
(143 MB heap size)
(144 MB heap size)
(145 MB heap size)
(154 MB heap size)
(156 MB heap size)
(157 MB heap size)
(166 MB heap size)
(168 MB heap size)
(169 MB heap size)
(179 MB heap size)
(181 MB heap size)
(182 MB heap size)
(191 MB heap size)
(193 MB heap size)
(194 MB heap size)
(203 MB heap size)
(205 MB heap size)
(207 MB heap size)
(216 MB heap size)
(217 MB heap size)
(219 MB heap size)
(228 MB heap size)
(230 MB heap size)
(231 MB heap size)
(240 MB heap size)
(242 MB heap size)
(243 MB heap size)
(252 MB heap size)
(254 MB heap size)
(255 MB heap size)
(264 MB heap size)
(266 MB heap size)
(267 MB heap size)
(277 MB heap size)
(278 MB heap size)
(280 MB heap size)
(195 MB heap size)
(204 MB heap size)
(203 MB heap size)
(212 MB heap size)
(213 MB heap size)
(214 MB heap size)
(223 MB heap size)
(224 MB heap size)
(226 MB heap size)
(235 MB heap size)
(237 MB heap size)
(238 MB heap size)
(247 MB heap size)
(249 MB heap size)
(250 MB heap size)
(259 MB heap size)
(261 MB heap size)
(262 MB heap size)
(271 MB heap size)
(273 MB heap size)
(274 MB heap size)
(283 MB heap size)

Test Suites: 100 passed, 100 total
Tests:       1000 passed, 1000 total
Snapshots:   0 total
Time:        11.347 s, estimated 12 s

解消方法

npm i -D @side/jest-runtime などで@side/jest-runtimeを導入し、jest.config.jsに以下の行を追加することで改善します。

  runtime: '@side/jest-runtime',

このコードで何か既存の実装やテストに影響が発生するかどうかは確認していませんが、後述する@side/jest-runtimeが生まれる切欠になったPRの様子を見る限り大丈夫なんじゃないかなとなんとなく思っています。

メモリリークが改善したあとの様子

(39 MB heap size)
(39 MB heap size)
(47 MB heap size)
(47 MB heap size)
(34 MB heap size)
(42 MB heap size)
(41 MB heap size)
(49 MB heap size)
(51 MB heap size)
(59 MB heap size)
(59 MB heap size)
(60 MB heap size)
(68 MB heap size)
(69 MB heap size)
(77 MB heap size)
(77 MB heap size)
(85 MB heap size)
(86 MB heap size)
(95 MB heap size)
(95 MB heap size)
(95 MB heap size)
(103 MB heap size)
(104 MB heap size)
(112 MB heap size)
(112 MB heap size)
(120 MB heap size)
(121 MB heap size)
(31 MB heap size)
(38 MB heap size)
(37 MB heap size)
(44 MB heap size)
(46 MB heap size)
(54 MB heap size)
(54 MB heap size)
(54 MB heap size)
(62 MB heap size)
(62 MB heap size)
(70 MB heap size)
(71 MB heap size)
(79 MB heap size)
(79 MB heap size)
(87 MB heap size)
(88 MB heap size)
(96 MB heap size)
(96 MB heap size)
(97 MB heap size)
(105 MB heap size)
(105 MB heap size)
(31 MB heap size)
(39 MB heap size)
(37 MB heap size)
(44 MB heap size)
(46 MB heap size)
(54 MB heap size)
(54 MB heap size)
(54 MB heap size)
(62 MB heap size)
(62 MB heap size)
(70 MB heap size)
(70 MB heap size)
(78 MB heap size)
(79 MB heap size)
(87 MB heap size)
(88 MB heap size)
(96 MB heap size)
(96 MB heap size)
(96 MB heap size)
(104 MB heap size)
(105 MB heap size)
(31 MB heap size)
(39 MB heap size)
(37 MB heap size)
(45 MB heap size)
(46 MB heap size)
(54 MB heap size)
(54 MB heap size)
(54 MB heap size)
(62 MB heap size)
(63 MB heap size)
(70 MB heap size)
(71 MB heap size)
(78 MB heap size)
(79 MB heap size)
(87 MB heap size)
(88 MB heap size)
(96 MB heap size)
(96 MB heap size)
(97 MB heap size)
(105 MB heap size)
(105 MB heap size)
(30 MB heap size)
(37 MB heap size)
(45 MB heap size)
(43 MB heap size)
(44 MB heap size)
(51 MB heap size)
(51 MB heap size)
(59 MB heap size)
(59 MB heap size)
(67 MB heap size)

Test Suites: 100 passed, 100 total
Tests:       1000 passed, 1000 total
Snapshots:   0 total
Time:        9.071 s, estimated 11 s

参考記事

メモリリークバグに関するIssue
https://github.com/jestjs/jest/issues/11956

@side/jest-runtimeが出来る元になったPR
https://github.com/jestjs/jest/pull/12205

投稿日:
Node.js

主な変更点に関して個人的にざっくりまとめてみる。詳細は公式のリリースノートを確認すること。v18やv16と比べるとアップデートが多い気がする。個人的にはv12, v14並に嬉しいアップデートかも。特に単一バイナリのサポートとARM64版Windowsのサポートが強力だと思います。

権限モデルの追加

--experimental-permissionフラグを付けることでファイル システム操作、子プロセスの生成、ワーカー スレッドの作成など、プログラムの実行中に特定のリソースへのアクセスを制限できる様になった模様。

実験的機能であれば16のときにもあったと思うが、この時との違いは詳しく確認してない。参考程度にv18とlatestとのドキュメントリンクを置いておく。

  • https://nodejs.org/api/permissions.html#process-based-permissions
  • https://nodejs.org/docs/latest-v18.x/api/permissions.html#process-based-permissions

専用スレッドで実行されるカスタムESMローダーフックの追加

多分ESMを別スレッドで読み込むことでヒープ競合が起きなくなるのだと思うが、使ったことがないので詳細は不明。TypeScriptを使っている限り、まだまだCJSを使う機会が多く、ESMへの完全移行は先な気がするので放置で良さそう?誰か詳しい人教えて!

同期的になったimport.meta.resolve()

詳細不明なのでパス。以下の記事が参考になるかも?

https://sosukesuzuki.dev/posts/nodejs-import-meta-resolve/

V8エンジンのアップデート

Chromium 113の一部であるバージョン11.3に更新されたそうです。前回は 11.2 だったようですが、いつのバージョンに入ってたのかは未確認。

テストランナーが安定版になった

Test runnerがStableになり本番環境で使えるレベルとなった。Jestより貧弱な気しかしないが一度は試してみたいところです。

https://nodejs.org/api/test.html

Ada 2.0の導入

URI解析のパフォーマンスが向上したほか、URLホスト名解析が楽になったっぽい?

単一実行バイナリの生成に対応

実験的な機能ですが単一実行バイナリの生成がサポートされた。リリースノートを読む限りNode.jsランタイムをバンドルすることで実現してそうなので、Electronみたいに巨大なバイナリができそうな気がする。興味深いので是非試してみたい機能。

https://nodejs.org/api/single-executable-applications.html

Web暗号化APIの互換性向上

恐らく主要なブラウザに実装されているWebCrypt実装を模倣したものが追加された?

https://github.com/nodejs/node/pull/46067

ARM64版Windowsのサポート

Node.jsの全機能がARM64版Windowsで動作するようになったとのこと。Surface Pro Xとかでも動くようになるものと思われる。単一実行バイナリの作成と合わせて嬉しい機能です。

WebAssembly System Interfaceインスタンス作成時にバージョン指定を必須に

new WASI()でバージョン指定が必須になりました。

https://nodejs.org/api/wasi.html
https://github.com/nodejs/node/pull/47391

url.parse()に無効なポート番号を持つURLを渡すと警告を出すように

次のようなURLを渡すと実行時に警告が出るようになりました。

url.parse('https://evil.com:.example.com');
url.parse('git+ssh://git@github.com:npm/npm');

https://github.com/nodejs/node/pull/45526

LTSスケジュール

20系がLTSに入るのは2023-10-24からです。

https://github.com/nodejs/release#release-schedule

投稿日:
Node.js::Prettier言語::Markdown

Prettier の次期 Major release である v3 がそろそろ出そうなので、今回含まれる大きな変更の一つについての記事です。

内容としてはMarkdown文書の全角文字と半角英数の間にスペースが挿入されなくなります。個人的にこれは相当賛否が分かれると思うので、v3が出た後の対処方法も併せて書いておきます。

変更点

以下は漢字Alphabetsひらがな12345カタカナ67890という文字列が渡された時の変化例です。

v3 より前 v3 以降
漢字 Alphabets ひらがな 12345 カタカナ 67890 漢字Alphabetsひらがな12345カタカナ67890

多分スペースが空いたときのほうが好みといった人が一定数居ると思います。個人的には検索性が破滅的に低下するためこの機能には否定的でしたが、今回のアップデートで遂にスペースが入らなくなったので、割と喜んでいます。ぶっちゃけこのブログの過去記事ににある余計なスペースも現在全て除去する作業をしてるくらいです。

発端としては次のIssueにあり、これをずっと追っていた人もきっといると思います。私もそのうちの一人です。

この問題は次のPullRequestで対応され、現在v3 用のブランチに取り込まれています。

  • [Markdown[next branch]: Do not insert spaces between Chinese/Japanese & latin letters #11597](https://github.com/prettier/prettier/pull/11597)

この機能を試したい場合、以下のようにインストールすることで試すことが出来ます。

npm i -D https://github.com/prettier/prettier.git#next

なお、PullRequestにある通り既にあるものには影響しないので、既にあるスペースを詰めることは出来ません。

これまで通りスペースを入れたい場合

引き続き漢字Alphabetsひらがな12345カタカナ67890漢字 Alphabets ひらがな 12345 カタカナ 67890にしたいケース

前述のPullRequestの中でも言及されていますが、textlintを利用することで対応できます。ただPrettierと併用するのは難しいと思います。

導入方法

textlintにtextlint-rule-preset-ja-spacingを組み合わせて対応します。

npm i -D textlint textlint-rule-preset-ja-spacing
使用方法

まず.textlintrcを作成し、次の設定を追加します。

{
    "rules": {
        "preset-ja-spacing": {
            "ja-space-between-half-and-full-width": {
                "space": "always"
            }
        }
    }
}

CLIを用いて修正する場合

npx textlint --fix <path>

VSCodeを用いる場合、vscode-textlintをインストールし、settings.jsonに以下の設定を追加することで利用することが出来ます。Prettierと併用する場合、Prettierと競合し、textlintが負けるため、Markdownの整形に関してはtextlintに任せるのが無難だと思います。

{
    "textlint.autoFixOnSave": true
}

一応Custom Local Formattersを使えば近いことは出来なくもないのですが、ファイルへの書き込み同期がズレてるのか上手く動かない気がします。一応以下の設定で動くことには動きますが、あんま期待しないほうがいいかも…。拡張からコマンドを蹴ること自体セキュリティ的に微妙な気がするので、この方法は使わないのが無難な気がします。

{
    "[markdown]": {
        "editor.formatOnSave": true,
        "editor.defaultFormatter": "jkillian.custom-local-formatters"
    },
    "customLocalFormatters.formatters": [
        {
            "command": "npx prettier ${file} | npx textlint --stdin --stdin-filename ${file} --fix --format fixed-result",
            "languages": ["markdown"]
        }
    ]
}

これはPrettierとtextlintを呼ぶ時点で二重処理になって重いので、Markdownのフォーマットはどっちか片方にやらせたほうが良いでしょう。ただtextlintはデフォルトだと表の整形とかいい感じにしてくれないようなのが困りものです。textlintにmarkdownプラグインを入れると改善されるかもしれませんが試してないです。

これまでのスペースを消したい場合

既にある漢字 Alphabets ひらがな 12345 カタカナ 67890漢字Alphabetsひらがな12345カタカナ67890にしたいケース

前項の「これまで通りスペースを入れたい場合」で紹介した環境を導入し、.textlintrcを以下のように作成すればある程度は対応できます。但し完全には除去しきれません。

{
    "rules": {
        "preset-ja-spacing": {
            "ja-space-between-half-and-full-width": {
                "space": "never"
            }
        }
    }
}

この設定によるtextlintの整形結果は以下のとおりです。記号の前後の半角スペースや見出しやリンク文章にある半角スペースは除去しきれないようです。

 ## 123 あああ abc

-ユーザー ID は Slack アプリから
+ユーザー IDはSlackアプリから
 やよいの ○ 色申告
 [Web サイトの example](https://example.com)

余談

多分大抵の人はPrettierをv3にしてからこの事に気が付くと思います。当然です。一々 OSSのアップデートを追っている人は恐らくメンテナか物好きだからです。

現在Prettierのリポジトリでは似たような議論として、デフォルトでインデントにスペースを使っているところをタブに置き換えようという動きがあります。幸いv3では採用に至っていませんが、今後採用される可能性があり、メンテナであるSosuke Suzuki氏もこの事について以下の記事で憂慮されています。

ある日突然インデントのスペースがタブに変われば、苦情が出るのは明白です。設定一つ直すだけとは言え、影響は大きいでしょう。CIで全体にPrettierを掛けていないプロジェクトでは一時的にタブインデントのファイルとスペースインデントのファイルが混在するケースもあると思います。空白文字の差分表示や検出を無効化していると気づかないまま、そのような状態になるケースもあると思います。

個人的にはMarkdownに落とした時にどの環境でも同じように見えるという理由でスペースインデントが好みですが、A11yの観点からタブにせよという考えも理解は出来ます。でもブラウザで.mdファイル見たときとかに見づらいんですよね…。PythonPHPYAML の様に標準がスペースインデントの言語もありますし、個人的には標準はスペースで構わないのではと思ったりもします。(その上で必要に応じてタブを使うという選択肢は出来ると思います。Google JavaScript Style Guideでも、これが標準です。Prettierは代表的なフォーマッタで事実上のコーディング標準であるとも言えるため、社会的責務でそうする必要があるかもしれませんが、個人的な思いとしては標準はスペースのままであってほしいなぁと思います。どうしても標準が変わると、世間はそこに引きずられざれ得ないと思いますから。

多分今回の変更も、上記のuseTab同様に一部で非難が上がる気がしていて、余り大事にならなければいいなぁ…とか考えています。(タブと比べると影響範囲が狭いので、ひょっとしたら今回はあまり何も起きないかもですが…)

この記事ではGitHub ActionsのCustom actionをJavaScriptで実装するJavaScript actionをTypeScriptとSWCを使って実装した方法を書いてます。

モチベーション

  • GitHub ActionsのWorkflowsを共通化したい
  • TypeScriptでロジックを書きたい
  • SWCを使いたい(nccはtscを使うので避けたい
  • github-scriptは一定以上のボリュームがあるものには向かない
    • これを使いつつUTを書いたりするとなると結構面倒になると思う

今回作るもの

Custom actionのうちJavaScript actionを作成します。
実装コードはTypeScript、トランスパイラはSWC、バンドラはwebpackを利用します。
バンドラを利用するのは、node_modules/をGitで管理したくないためです。
ビルド成果物であるdist/は実行時に必要なため、Gitで管理します。
(CI上でビルドしてキャッシュさせておくことも出来ると思いますが、今回は扱いません)

確認環境

Env Ver
@actions/core 1.10.0
@actions/github 5.1.1
@swc/cli 0.1.57
@swc/core 1.3.26
swc-loader 0.2.3
typescript 4.9.5
webpack 5.75.0
webpack-cli 5.0.1

サンプルコード

Custom Actions本体

Custom action本体のサンプルコードです。以下に一式があります。
https://github.com/Lycolia/typescript-code-examples/tree/main/swc-ts-custom-actions

ディレクトリ構成

dist/配下を叩くため、ここはGit管理に含めます。バンドルするのでnode_modules/はGit管理から外して問題ありません。

├─dist/
│ └─index.js        # Custom Actionsとして実行するファイル本体
├─node_modules/
├─src/
│ └─index.ts        # TypeScript実装
├─action.yaml       # Custom Actionsの定義
├─package-lock.json
├─package.json
├─swcrc-base.js     # SWCの設定
├─tsconfig.json     # tscの設定
└─webpack.config.js # webpackの設定
swcrc-base.js

SWCの設定例。特にJavaScript actionのための設定はなく、CLI向けのトランスパイルが出来る設定ならおk。
ファイル名は何でも大丈夫ですが、この場では.swcrcにしないことで、直接SWCで利用しないことを判りやすくするために違う名前にしています。

module.exports = {
    module: {
        type: 'commonjs',
    },
    jsc: {
        target: 'es2020',
        parser: {
            syntax: 'typescript',
            tsx: false,
            decorators: false,
            dynamicImport: false,
        },
        baseUrl: '.',
        paths: {
            'src/*': ['src/*'],
        },
    },
};
webpack.config.js

SWCを使って.tsファイルをバンドルするための設定。これがないとimportの解決ができずにコケます。
node_modules/配下をGit管理に含める場合は不要かもしれませんが、それをするのは微妙だと思います。

const path = require('path');
const swcrcBase = require(path.resolve(__dirname, 'swcrc-base'));

module.exports = {
    // エントリポイント
    entry: path.resolve(__dirname, 'src/index.ts'),
    // 出力設定
    output: {
        // クリーンアップ後に出力
        clean: true,
        // 出力ファイル名
        filename: 'index.js',
        // 出力パス
        path: path.resolve(__dirname, 'dist'),
    },
    // 設定必須なので何か指定しておく
    mode: 'production',
    // 指定してないとNode.jsのネイティブAPIが呼べない
    target: ['node'],
    module: {
        // swc-loaderの設定
        rules: [
            {
                test: /\.ts$/,
                exclude: /(node_modules)/,
                use: {
                    loader: 'swc-loader',
                    // swcrcの設定
                    options: {
                        ...swcrcBase,
                    },
                },
            },
        ],
    },
    resolve: {
        // import時のファイル拡張子を省略してる場合にパスを解決するための設定
        extensions: ['', '.ts', '.js'],
    },
};
src/index.ts

最低限これだけ確認できれば応用して実装できるだろうという程度のサンプルコード。

@actions/*系の使い方は以下のリンクから確認できます。
actions/toolkit: The GitHub ToolKit for developing GitHub Actions.

import * as core from '@actions/core';
import * as github from '@actions/github';

const githubToken = core.getInput('GITHUB_TOKEN', { required: true });
const octokit = github.getOctokit(githubToken);

console.log('octokit', octokit);
console.log('context', github.context);
core.setOutput('RESULT_MESSAGE', 'test result message');
action.yaml

実装の参考例として引数と出力を定義してます。特に不要な場合は書かなくてもいいです。
Node.jsのバージョンを詳細に指定したい場合は、composite action にすれば可能だとは思いますが、試してない。
composite actionにしてnvmか何かでインストールしてやれば恐らく可能。

構文は以下のページで確認できます。
GitHub Actions のメタデータ構文 - GitHub Docs

name: example
description: custom actions example
inputs:
  GITHUB_TOKEN:
    description: 'Repogitory GITHUB_TOKEN'
    required: true
outputs:
  RESULT_MESSAGE:
    description: 'Result message'
on:
  workflow_call:
runs:
  using: node16
  main: dist/index.js

Custom actionを使う側

Custom actionを使うWorkflowのサンプルコードです。以下にソースがあります。
https://github.com/Lycolia/custom-actions-usage-example

.github/workflows/example.yaml

usesのところにはリポジトリの組織名と、リポジトリ名、action.yamlが配置されているディレクトリまでのパスを書きます。ルートディレクトリにある場合はパスを書かなくてOK
最後に@sha-hashでコミットハッシュかタグを付けてやれば呼べるようになります。
動作確認中はハッシュが頻繁に変わるので、最新のハッシュを取得してきて設定されるようにしておくと便利かもしれません。

name: run example
on:
  workflow_dispatch:
jobs:
  example:
    runs-on: ubuntu-latest
    steps:
      - name: run custom actions
        id: test
        uses: org-name/repo-name/path/to/file@sha-hash
        with:
          # Custom action側で定義されている引数(input)の設定
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      - name: show custom actions output
        # Custom action側で定義されている出力(output)の取得
        run: echo ${{ steps.test.outputs.RESULT_MESSAGE }}

参考資料

本記事ではTypeScriptとSWC、Jestの組み合わせ環境でjest.spyOnを使う方法をexportパターン別に書いていきます。また、jest.spyOn対象の実装の詳細はテストせず、モック化したインターフェースのテストのみを行う前提で書いています。

確認環境

Env Ver
Node.js 18.14.0
typescript 4.9.5
@swc/cli 0.1.62
@swc/core 1.3.36
@swc/jest 0.2.24
jest 29.4.3

サンプルコード

全編は以下にあります
jest.spyOn pattern example for SWC + TypeScript

親関数から子関数を呼び出すパターンで、子関数のjest.spyOnをするシチュエーションで考えます。

default export編

以下のdefaultエクスポートをjest.spyOnする方法です。

child.ts
export const child = (param: string) => {
  return param;
};
parent.ts
import child from 'src/default/child';

const parent = (param: string) => {
  return child(param);
};

export const Parent = {
  parent,
};
parent.spec.ts

この形式の場合import側では書き換え不能なのでファイルごとモックにして解決します。
このやり方はファイルごとモックするため、同じファイルにある関数をjest.spyOnするのには向いていません。

import * as child from 'src/default/child';
import { Parent } from 'src/default/parent';

jest.mock('src/default/child');

describe('default', () => {
  it('called by child function', () => {
    const spiedChild = jest.spyOn(child, 'default');
    Parent.parent('foo');

    expect(spiedChild).toHaveBeenCalledWith('foo');
  });
});

named export編

以下の名前付きエクスポートをjest.spyOnする方法です。

child.ts
export const child = (param: string) => {
  return param;
};
parent.ts
import { child } from 'src/named-export/child';

export const parent = (param: string) => {
  return child(param);
};
parent.spec.ts

default exportと同じくimport側では書き換え不能なのでファイルごとモックにして解決します。
このやり方はファイルごとモックするため、同じファイルにある関数をjest.spyOnするのには向いていません。

import * as child from 'src/named-export/child';
import { parent } from 'src/named-export/parent';

jest.mock('src/named-export/child');

describe('function', () => {
  it('called by child function', () => {
    const spiedChild = jest.spyOn(child, 'child');
    parent('foo');

    expect(spiedChild).toHaveBeenCalledWith('foo');
  });
});

namespace export編

やり方はnamed export編と同じです。

child.ts
export namespace Child {
  export const child = (param: string) => {
    return param;
  };
}
parent.ts
import { Child } from 'src/namespace/child';

export namespace Parent {
  export const parent = (param: string) => {
    return Child.child(param);
  };
}
parent.spec.ts

namespaceの場合、トランスパイル後にクロージャになり、書き換え以前に関数にアクセスできなくなるため、ファイルごとモックにして解決します。
このやり方はファイルごとモックするため、同じファイルにある関数をjest.spyOnするのには向いていません。

import { Child } from 'src/namespace/child';
import { Parent } from 'src/namespace/parent';

jest.mock('src/namespace/child');

describe('namespace', () => {
  it('called by child function', () => {
    const spiedChild = jest.spyOn(Child, 'child');
    Parent.parent('foo');

    expect(spiedChild).toHaveBeenCalledWith('foo');
  });
});

module export編

これだけやり方が変わります。

child.ts
const child = (param: string) => {
  return param;
};

export const Child = {
  child,
};
parent.ts
import { Child } from 'src/module/child';

const parent = (param: string) => {
  return Child.child(param);
};

export const Parent = {
  parent,
};
parent.spec.ts

このケースの場合オブジェクトをexportしていて書き換えができるため、モック化せずにそのままjest.spyOnすることが出来ます。
このやり方であれば、ファイルのモックはしないため、同じファイルにある関数をjest.spyOnする事ができますが、裏を返せばオブジェクトの書き換えが可能であるため、実装方法によっては予期せぬ不具合が生まれる可能性があり、危険です。
何かしらの悪意ある攻撃を受けた場合にオブジェクトがすり替わるなどすると致命的だと思います。

import { Child } from 'src/module/child';
import { Parent } from 'src/module/parent';

describe('module', () => {
  it('called by child function', () => {
    const spiedChild = jest.spyOn(Child, 'child');
    Parent.parent('foo');

    expect(spiedChild).toHaveBeenCalledWith('foo');
  });
});

おまけ:それぞれのトランスパイル結果

.spec.jsは省いてます

default export編

child.js
'use strict';
Object.defineProperty(exports, '__esModule', {
  value: true,
});
Object.defineProperty(exports, 'default', {
  enumerable: true,
  get: () => _default,
});
const child = (param) => {
  return param;
};
const _default = child;
parent.js
'use strict';
Object.defineProperty(exports, '__esModule', {
  value: true,
});
Object.defineProperty(exports, 'Parent', {
  enumerable: true,
  get: () => Parent,
});
const _child = /*#__PURE__*/ _interopRequireDefault(require('./child'));
function _interopRequireDefault(obj) {
  return obj && obj.__esModule
    ? obj
    : {
        default: obj,
      };
}
const parent = (param) => {
  return (0, _child.default)(param);
};
const Parent = {
  parent,
};

named export編

child.js
'use strict';
Object.defineProperty(exports, '__esModule', {
  value: true,
});
Object.defineProperty(exports, 'child', {
  enumerable: true,
  get: () => child,
});
const child = (param) => {
  return param;
};
parent.js
'use strict';
Object.defineProperty(exports, '__esModule', {
  value: true,
});
Object.defineProperty(exports, 'parent', {
  enumerable: true,
  get: () => parent,
});
const _child = require('./child');
const parent = (param) => {
  return (0, _child.child)(param);
};

namespace export編

child.js
'use strict';
Object.defineProperty(exports, '__esModule', {
  value: true,
});
Object.defineProperty(exports, 'child', {
  enumerable: true,
  get: () => child,
});
const child = (param) => {
  return param;
};
parent.js
'use strict';
Object.defineProperty(exports, '__esModule', {
  value: true,
});
Object.defineProperty(exports, 'Parent', {
  enumerable: true,
  get: () => Parent,
});
const _child = require('./child');
var Parent;
(function (Parent) {
  var parent = (Parent.parent = (param) => {
    return _child.Child.child(param);
  });
})(Parent || (Parent = {}));

module export編

child.js
'use strict';
Object.defineProperty(exports, '__esModule', {
  value: true,
});
Object.defineProperty(exports, 'Child', {
  enumerable: true,
  get: () => Child,
});
const child = (param) => {
  return param;
};
const Child = {
  child,
};
parent.js
'use strict';
Object.defineProperty(exports, '__esModule', {
  value: true,
});
Object.defineProperty(exports, 'Parent', {
  enumerable: true,
  get: () => Parent,
});
const _child = require('./child');
const parent = (param) => {
  return _child.Child.child(param);
};
const Parent = {
  parent,
};

この記事を書いた切っ掛け

一言でいうとTSC(TypeScript Compiler) からSWCに移行した際にjest.spyOnを使ったら、上手く使えずにハマったためです。

まずTypeScript + JestではTypeScriptをCJS(CommonJS) にトランスパイルするためjest.spyOnが有効でした。しかし、SWCはTypeScriptをESM(ES Modules) にトランスパイルします。ESMではjest.spyOnが有効になりません。

これはCJSではexportしたObjectの書き換えが可能なのに対し、ESMでは出来ないためです。(jest.spyOnは実行時にオブジェクトを書き換えることで動いています)

この対策としてファイルそのものをモックに置き換えることで、jestの管理下に起き、自由に書き換えられるようにするのが本記事のアプローチです。以前と異なり、呼び出している関数の実装の詳細を見ることはできなくなりましたが、これは単体テストの観点としては正しいため、ある意味本来の単体テストになったとも取れると考えています。(モックに元の処理を注入することで今まで通り実装の詳細を確認することも出来る可能性がありますが、確認してません)