使用 Jest/Typescript 测试 fs 库函数

Posted

技术标签:

【中文标题】使用 Jest/Typescript 测试 fs 库函数【英文标题】:Testing a fs library function with Jest/Typescript 【发布时间】:2019-03-04 14:52:01 【问题描述】:

我正在尝试测试我编写的库函数(它在我的代码中有效),但无法使用 fs 的模拟进行测试。我有一系列函数用于处理封装在函数中的操作系统,因此应用程序的不同部分可以使用相同的调用。

我尝试按照 this question 模拟文件系统,但它似乎对我不起作用。

下面是一个简短的示例来演示我的问题的基本情况:

import * as fs from 'fs';
export function ReadFileContentsSync(PathAndFileName:string):string 
    if (PathAndFileName === undefined || PathAndFileName === null || PathAndFileName.length === 0) 
        throw new Error('Need a Path and File');
    
    return fs.readFileSync(PathAndFileName).toString();

所以现在我正在尝试使用 Jest 测试这个功能:

import  ReadFileContentsSync  from "./read-file-contents-sync";
const fs = require('fs');

describe('Return Mock data to test the function', () => 
    it('should return the test data', () => 
        const TestData:string = 'This is sample Test Data';

// Trying to mock the reading of the file to simply use TestData
        fs.readFileSync = jest.fn();                
        fs.readFileSync.mockReturnValue(TestData);

// Does not need to exist due to mock above     
        const ReadData = ReadFileContentsSync('test-path');
        expect(fs.readFileSync).toHaveBeenCalled();
        expect(ReadData).toBe(TestData);
    );
);

我收到一个文件不存在的异常,但我预计不会调用 fs.readFileSync 的实际调用,而是使用了 jest.fn() 模拟。

ENOENT: no such file or directory, open 'test-path'

我不知道怎么做这个模拟?

【问题讨论】:

尝试在您的tsconfig 中启用esModuleInterop 并使用import fs from 'fs' 并尝试agin。 import * as fs ... 正在复制,因此您的直接模拟将不起作用。 您可以使用 jest 中的 lib 模拟来模拟,而不是模拟一个函数。或者,考虑使用函数式编程模式/OO 来管理您的依赖关系。 jestjs.io/docs/en/bypassing-module-mocks @unional 我尝试了您的建议以启用 esModuleInterop,然后是 import fs from 'fs',但模拟并未发生,因为未返回模拟数据,但尝试访问明显不存在的文件。 ENOENT: no such file or directory, open test-path 那么你必须使用开玩笑的绕过模拟。就我个人而言,我不是它的朋友。我宁愿遵循函数式编程约定。 【参考方案1】:

虽然工会的评论帮助我指出了正确的方向,但 fs 的导入是在我的代码中作为 import * as fs from 'fs' 完成的。这似乎是问题所在。将此处的导入更改为 import fs from 'fs' 即可解决问题。

因此,代码变为:

import fs from 'fs';
export function ReadFileContentsSync(PathAndFileName:string):string 
    if (PathAndFileName === undefined || PathAndFileName === null || PathAndFileName.length === 0) 
        throw new Error('Need a Path and File');
    
    return fs.readFileSync(PathAndFileName).toString();

还有测试文件:

jest.mock('fs');
import  ReadFileContentsSync  from "./read-file-contents-sync";

import fs from 'fs';

describe('Return Mock data to test the function', () => 
    it('should return the test data', () => 
        const TestData:Buffer = new Buffer('This is sample Test Data');

// Trying to mock the reading of the file to simply use TestData
        fs.readFileSync = jest.fn();                
        fs.readFileSync.mockReturnValue(TestData);

// Does not need to exist due to mock above     
        const ReadData = ReadFileContentsSync('test-path');
        expect(fs.readFileSync).toHaveBeenCalled();
        expect(ReadData).toBe(TestData.toString());
    );
);

【讨论】:

【参考方案2】:

由于我提到了函数式/OO/以及不喜欢jest mock,我觉得我应该在这里填写一些解释。

我不反对jest.mock() 或任何模拟库(例如sinon)。 我以前用过它们,它们肯定能达到目的,是一个有用的工具。 但我发现自己大部分情况下都不需要它们,并且在使用它们时会有一些权衡。

我先演示一下不使用mock就可以实现代码的三种方式。

第一种方式是函数式的,使用context作为第一个参数:

// read-file-contents-sync.ts
import fs from 'fs';
export function ReadFileContentsSync( fs  =  fs , PathAndFileName: string): string 
    if (PathAndFileName === undefined || PathAndFileName === null || PathAndFileName.length === 0) 
        throw new Error('Need a Path and File');
    
    return fs.readFileSync(PathAndFileName).toString();


// read-file-contents-sync.spec.ts
import  ReadFileContentsSync  from "./read-file-contents-sync";

describe('Return Mock data to test the function', () => 
    it('should return the test data', () => 
        const TestData:Buffer = new Buffer('This is sample Test Data');

        // Trying to mock the reading of the file to simply use TestData
        const fs = 
            readFileSync: () => TestData
        

        // Does not need to exist due to mock above     
        const ReadData = ReadFileContentsSync( fs , 'test-path');
        expect(ReadData).toBe(TestData.toString());
    );
);

第二种方式是使用OO:

// read-file-contents-sync.ts
import fs from 'fs';
export class FileReader 
    fs = fs
    ReadFileContentsSync(PathAndFileName: string) 
        if (PathAndFileName === undefined || PathAndFileName === null || PathAndFileName.length === 0) 
            throw new Error('Need a Path and File');
        
        return this.fs.readFileSync(PathAndFileName).toString();
    


// read-file-contents-sync.spec.ts
import  FileReader  from "./read-file-contents-sync";

describe('Return Mock data to test the function', () => 
    it('should return the test data', () => 
        const TestData: Buffer = new Buffer('This is sample Test Data');

        const subject = new FileReader()
        subject.fs =  readFileSync: () => TestData  as any

        // Does not need to exist due to mock above     
        const ReadData = subject.ReadFileContentsSync('test-path');
        expect(ReadData).toBe(TestData.toString());
    );
);

第三种方式使用修改后的函数样式,它需要 TypeScript 3.1(从技术上讲,您可以在 3.1 之前这样做,但涉及命名空间 hack 会有点笨拙):

// read-file-contents-sync.ts
import fs from 'fs';
export function ReadFileContentsSync(PathAndFileName: string): string 
    if (PathAndFileName === undefined || PathAndFileName === null || PathAndFileName.length === 0) 
        throw new Error('Need a Path and File');
    
    return ReadFileContentsSync.fs.readFileSync(PathAndFileName).toString();

ReadFileContentsSync.fs = fs

// read-file-contents-sync.spec.ts
import  ReadFileContentsSync  from "./read-file-contents-sync";

describe('Return Mock data to test the function', () => 
    it('should return the test data', () => 
        const TestData: Buffer = new Buffer('This is sample Test Data');

        // Trying to mock the reading of the file to simply use TestData
        ReadFileContentsSync.fs = 
            readFileSync: () => TestData
         as any

        // Does not need to exist due to mock above     
        const ReadData = ReadFileContentsSync('test-path');
        expect(ReadData).toBe(TestData.toString());
    );
);

前两种方式提供了更大的灵活性和隔离性,因为每个调用/实例都有自己的依赖引用。 这意味着一个测试的“模拟”不会影响另一个测试。

第三种方式并不能阻止这种情况的发生,但具有不改变原始函数签名的好处。

所有这些的底部是依赖管理。 大多数情况下,一个程序或代码难以维护、使用或测试是因为它没有为调用上下文提供一种方法来控制其被调用者的依赖关系。

依赖模拟库(尤其是像jest.mock() 这样强大的模拟系统)很容易养成忽略这一重要方面的习惯。

我推荐大家看看的一篇好文章是鲍勃叔叔的清洁架构:https://8thlight.com/blog/uncle-bob/2012/08/13/the-clean-architecture.html

【讨论】:

我喜欢这些答案,但有一些注意事项/问题:#1 export function ReadFileContentsSync( fs = fs ... 这里的第一个参数意味着我们所有的调用代码也必须导入 fs,因为它需要传递给库函数。如果我们更改为与 fs 不同的处理程序,那将涉及很多更改。对于#2,我认为这是更好的选择,除了 fs 是公共的,并且可以在调用库时被覆盖以使用不同于所需的东西(测试所需),因为我们只尝试使用 1 个库但代码审查应该会发现问题。 对于#1,是的,您的调用代码需要将其传入。本质上,这些“外部依赖”应由“主边界”(即应用程序)管理。对于#2,不确定您的意思。你能详细说明吗?总的来说,你说的是真的。它是公开的。我的论点是“javascript 中的所有属性”已经公开。 IMO 有时我们过于重视封装。我们应该知道什么可以做什么不能做什么,但没有必要将其限制在使我们难以/不可能有效地完成工作的程度。 关于“主要边界”,这是一个好的 DI 解决方案应该可用甚至融入 IMO 语言的原因之一。携带代码所需的所有依赖项很麻烦,而且很容易违反基本设计原则(低级细节更改会导致高级策略更改)。问题是学习有效和正确地使用 DI。这可以解决整个学校的问题。 更多的是我们有初级、中级和高级开发人员的混合体。越年轻,就越有可能改变公开的东西,他们可以在没有意识到影响的情况下改变。代码审查可以帮助跟踪这一点并教育初级(和中级),但我们可能已经提交了导致问题的代码。但是,我认为使用依赖注入的类是最好的方法,我将尝试更改我们的库。 你所说的“换行”是什么意思?你的意思是如何存根? fs = accessSync() throw ... fs = accessSync() ?

以上是关于使用 Jest/Typescript 测试 fs 库函数的主要内容,如果未能解决你的问题,请参考以下文章

Jest、Typescript、ts-jest:覆盖范围略有不正确

Jest typescript 测试运行两次,一次用于 ts 文件,一次用于 js 文件

在 Jest TypeScript 中找不到名称“它”

使用 jest typescript 模拟 Axios 的类型

ts-jest:TypeScript 类型错误被忽略

jest + typescript + es6 模块(又一次,2019 年)- SyntaxError: Unexpected token export