iT邦幫忙

2023 iThome 鐵人賽

0
自我挑戰組

用ChatGPT詠唱來完成工作與點亮前後端技能樹系列 第 53

【Day53】ChatGPT幫我完成工作:寫Jest單元測試!NestJS & Knex & Jest!

  • 分享至 

  • xImage
  •  

這一篇要來介紹使用ChatGPT來撰寫Jest單元測試的結果

有關Jest的介紹,可以看這兩篇:
【Day30】ChatGPT請教教我:Jest 單元測試(上) - 基本介紹&起始安裝
【Day31】ChatGPT請教教我:Jest 單元測試(下) - 完整語法&教學

Jest不只可以針對前端進行單元測試,也可以針對後端進行處理
那我們的前後端都是JavaScript / TypeScript ,在進行Jest的測試的時候語法幾乎都是一樣的

在Day30、Day31的地方有講過前端搭配Jest的安裝了
而後端NestJS這個框架,本身也有內建置Jest單元測試
在使用 npx nest g controller / npx nest g service 的時候
就會自動建立好,搭建好基本的template、也直接可以跑
https://ithelp.ithome.com.tw/upload/images/20231108/20135750uOHTROWKfn.png

唯一要注意的是,在beforeEach內的Module,就和正式的module一樣
必須把對應的providers或是import補上(如果有用到額外引入的)

接下來我們就來看看用ChatGPT來撰寫單元測試的效果如何吧

普通的純函數

這邊上兩個普通的純函數範例,基本上就是單純的JS程式碼
做一些字串轉換的小function,第一個範例是這個:

  1. 全形與半形字體轉換的function

    function toChange(str: string) {
        let result = "";
        const len: number = str.length;
        for (let i=0;i<len ;i++ )
        {
            let cCode: number = str.charCodeAt(i);
            cCode = (cCode>=0xFF01 && cCode<=0xFF5E)?(cCode - 65248) : cCode;
            cCode = (cCode===0x03000)? 0x0020 : cCode;
            result += String.fromCharCode(cCode);
        }
        return result;
     }
    

    我這邊故意將function名稱叫做「toChange」
    因為如果寫ToCDB 的話,網路上也有許多的資料可以直接知道他是一個全半形轉換的函數
    然後這個前後端都可以使用,就是一個普通的JS function

    https://ithelp.ithome.com.tw/upload/images/20231108/20135750BVWfbI82Of.png

    好的,我會幫你撰寫一些 Jest 單元測試案例來測試這個 toChange 函式。以下是測試程式碼:

    // 引入需要測試的函式
    import { toChange } from './your-file'; // 請將 'your-file' 替換為實際的檔案路徑
    
    describe('toChange 函式測試', () => {
      it('將全形英文字母轉換成半形', () => {
        const input = 'ABC';
        const expectedOutput = 'ABC';
        expect(toChange(input)).toBe(expectedOutput);
      });
    
      it('將全形數字轉換成半形', () => {
        const input = '123';
        const expectedOutput = '123';
        expect(toChange(input)).toBe(expectedOutput);
      });
    
      it('將全形符號轉換成半形', () => {
        const input = '!@#';
        const expectedOutput = '!@#';
        expect(toChange(input)).toBe(expectedOutput);
      });
    
      it('保留半形字元', () => {
        const input = 'Hello World!';
        const expectedOutput = 'Hello World!';
        expect(toChange(input)).toBe(expectedOutput);
      });
    
      it('保留空白字元', () => {
        const input = '  '; // 包含半形空格和全形空格
        const expectedOutput = '  ';
        expect(toChange(input)).toBe(expectedOutput);
      });
    
      it('處理空字串', () => {
        const input = '';
        const expectedOutput = '';
        expect(toChange(input)).toBe(expectedOutput);
      });
    });
    

    這些測試案例會檢查函式對於不同種類的輸入是否能夠正確轉換成半形字元,並且保留其他字元不變。請確保你在測試檔案中引入了需要測試的 toChange 函式,並將 'your-file' 替換為實際的檔案路徑。

    如果你還需要其他測試案例或有任何其他的需求,請隨時告訴我!

    如果是使用NestJS的話,那就自行從service中取出function來做使用測試
    可以看到他已經盡可能的找了各種方式去測試
    包含空字串、包含空格、包含符號等

    對於這種簡單的純函數測試,給予了相當不錯的方向與範例來寫測試

  2. Enum轉換

    function getKeyByValue(type: any ,value: string | number) {
        const indexOfS = Object.values(type).indexOf(value);
        const key = Object.keys(type)[indexOfS];
        return key;
    }
    

    這個function的效果,基本上是把Enum或是type、物件丟入,然後用Value找key
    來看看ChatGPT會怎麼寫這個小函數的單元測試

    https://ithelp.ithome.com.tw/upload/images/20231108/20135750j1gMJ1XDxr.png

    好的,我會幫你寫一些 Jest 單元測試案例來測試這個 getKeyByValue 函式。以下是測試程式碼:

    // 引入需要測試的函式
    import { getKeyByValue } from './your-file'; // 請將 'your-file' 替換為實際的檔案路徑
    
    enum ExampleEnum {
      One = 1,
      Two = 2,
      Three = 3
    }
    
    describe('getKeyByValue 函式測試', () => {
      it('正確取得對應值的鍵', () => {
        const inputType = ExampleEnum;
        const inputValue = 2;
        const expectedOutput = 'Two';
        expect(getKeyByValue(inputType, inputValue)).toBe(expectedOutput);
      });
    
      it('值不存在時返回 undefined', () => {
        const inputType = ExampleEnum;
        const inputValue = 4; // 不存在的值
        const expectedOutput = undefined;
        expect(getKeyByValue(inputType, inputValue)).toBe(expectedOutput);
      });
    
      it('處理字串值', () => {
        const inputType = { A: 'apple', B: 'banana', C: 'cherry' };
        const inputValue = 'banana';
        const expectedOutput = 'B';
        expect(getKeyByValue(inputType, inputValue)).toBe(expectedOutput);
      });
    
      it('處理特殊情況 - 值為 0', () => {
        const inputType = { A: 0, B: 1, C: 2 };
        const inputValue = 0;
        const expectedOutput = 'A';
        expect(getKeyByValue(inputType, inputValue)).toBe(expectedOutput);
      });
    });
    

    這些測試案例會檢查函式對於不同類型的輸入是否能夠正確取得對應的鍵,以及處理值不存在的情況。請確保你在測試檔案中引入了需要測試的 getKeyByValue 函式,並將 'your-file' 替換為實際的檔案路徑。

    如果你還需要其他測試案例或有任何其他的需求,請隨時告訴我!

    涵括了Enum與物件的的尋找,還有「找不到值」的情況,我覺得相當不錯
    有需要的話,也可以再自己擴充其他的test case

Mock DataBase & Knex

接下來要處理的Case是稍微複雜一點的,也就是有跟資料庫交互的
若需要根據取得的資料不同,會有不一樣的處理程序與結果的話
要怎麼進行單元測試才好呢?

上範例程式碼之前,先說明一下範例程式碼的邏輯

1. 用id取得A表資料,拿到type 和 value
2. 如果取得的type = B ,去B表用id 找資料 value, 把兩個資料加起來 
3. 如果取得的type = C ,去C表用id 找資料 value, 把兩個資料成起來
4. 如果取得的資料是空 => 拋出404找不到
5. 出現非B或C的 type,拋出例外

然後這邊是主要要測試的NestJS程式碼

// file-data/file-data.service.ts
import { DbService } from '../db/db.service';
import {
  Injectable,
  NotFoundException,
  UnprocessableEntityException,
} from '@nestjs/common';

@Injectable()
export class FileDataService {
  constructor(private readonly dbService: DbService) {}
	async calcData(id: string) {

    // 使用 Knex 從資料庫中獲取A表的資料
    const knex = this.dbService.getMysqlConnection();
    const aData = await knex('A').where({ id }).first();

    if (!aData) {
      throw new NotFoundException('找不到對應的資料');
    }

    if (aData.type === 'B') {
      // 使用 Knex 從B表中獲取資料
      const bData = await knex('B').where({ id }).first();
      if (!bData) {
        throw new NotFoundException('找不到對應的B表資料');
      }

      // 執行加法運算
      const result = aData.value + bData.value;
      return result;
    } else if (aData.type === 'C') {
      // 使用 Knex 從C表中獲取資料
      const cData = await knex('C').where({ id }).first();
      if (!cData) {
        throw new NotFoundException('找不到對應的C表資料');
      }

      // 執行乘法運算
      const result = aData.value * cData.value;
      return result;
    } else {
      // 非B或C的 type資料,拋出例外
      throw new NotFoundException('不支援的資料類型');
    }
  }
}

其他部分的程式碼

// file-data/file-data.module.ts
import { Module } from '@nestjs/common';
import { FileDataService } from './file-data.service';
import { FileDataController } from './file-data.controller';
import { DbModule } from 'src/db/db.module';

@Module({
  imports: [DbModule],
  providers: [FileDataService],
})
export class FileDataModule {}
// db/db.module.ts
import { Module } from '@nestjs/common';
import { DbService } from './db.service';

@Module({
  providers: [DbService],
  exports: [DbService],
})
export class DbModule {}

// db/db.service.ts
import * as dotenv from 'dotenv';
dotenv.config();
import * as knex from 'knex';

export class DbService {
  private mysqlConnection = knex({
    client: 'mysql2',
    connection: {
      host: process.env.MYSQL_HOST,
      user: process.env.MYSQL_USER,
      password: process.env.MYSQL_PASSWORD,
      database: 'mydatabase', // 替換為你的MySQL資料庫名稱
    },
  });
  getMysqlConnection() {
    return this.mysqlConnection;
  }
}

針對這樣的例子,希望ChatGPT可以生成對應的mock data
去假設資料庫回傳各種情況的資料,以符合每一種情況的測試

https://ithelp.ithome.com.tw/upload/images/20231108/20135750iKykzi4Owc.png

(以下略,將上述的程式碼全貼)

好的,讓我來幫你完成 file-data.service.spec.ts 的測試內容。首先,我們會寫一些測試案例來測試 calcData 函式的各種情況。

import { Test, TestingModule } from '@nestjs/testing';
import { FileDataService } from './file-data.service';
import { DbService } from '../db/db.service';
import { NotFoundException, UnprocessableEntityException } from '@nestjs/common';

describe('FileDataService', () => {
  let service: FileDataService;
  let dbService: DbService;
  let mockDbInstance: any;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [FileDataService, DbService],
    }).compile();

    service = module.get<FileDataService>(FileDataService);
    dbService = module.get<DbService>(DbService);

    mockDbInstance = {
      where: jest.fn().mockReturnThis(),
      first: jest.fn(),
    };

    const db: any = () => mockDbInstance;
    dbService.getMysqlConnection = jest.fn().mockReturnValue(db);
  });

  it('should be defined', () => {
    expect(service).toBeDefined();
  });

  it('should throw NotFoundException when A data is not found', async () => {
    mockDbInstance.first.mockResolvedValueOnce(undefined);

    try {
      await service.calcData('1');
    } catch (e) {
      expect(e).toBeInstanceOf(NotFoundException);
      expect(e.message).toBe('找不到對應的資料');
    }
  });

  it('should perform addition correctly for type B', async () => {
    const aData = { id: '1', type: 'B', value: 5 };
    const bData = { id: '1', value: 3 };

    mockDbInstance.first.mockResolvedValueOnce(aData);
    mockDbInstance.first.mockResolvedValueOnce(bData);

    const result = await service.calcData('1');
    expect(result).toBe(8);
  });

  it('should throw NotFoundException when B data is not found for type B', async () => {
    const aData = { id: '1', type: 'B', value: 5 };

    mockDbInstance.first.mockResolvedValueOnce(aData);
    mockDbInstance.first.mockResolvedValueOnce(undefined);

    try {
      await service.calcData('1');
    } catch (e) {
      expect(e).toBeInstanceOf(NotFoundException);
      expect(e.message).toBe('找不到對應的B表資料');
    }
  });

  it('should perform multiplication correctly for type C', async () => {
    const aData = { id: '1', type: 'C', value: 5 };
    const cData = { id: '1', value: 3 };

    mockDbInstance.first.mockResolvedValueOnce(aData);
    mockDbInstance.first.mockResolvedValueOnce(cData);

    const result = await service.calcData('1');
    expect(result).toBe(15);
  });

  it('should throw NotFoundException when C data is not found for type C', async () => {
    const aData = { id: '1', type: 'C', value: 5 };

    mockDbInstance.first.mockResolvedValueOnce(aData);
    mockDbInstance.first.mockResolvedValueOnce(undefined);

    try {
      await service.calcData('1');
    } catch (e) {
      expect(e).toBeInstanceOf(NotFoundException);
      expect(e.message).toBe('找不到對應的C表資料');
    }
  });

  it('should throw UnprocessableEntityException for unsupported type', async () => {
    const aData = { id: '1', type: 'D', value: 5 };

    mockDbInstance.first.mockResolvedValueOnce(aData);

    try {
      await service.calcData('1');
    } catch (e) {
      expect(e).toBeInstanceOf(UnprocessableEntityException);
      expect(e.message).toBe('不支援的資料類型');
    }
  });
});

這些測試案例將測試 calcData 函式的各種情況,包括找不到資料、正確執行加法和乘法運算,以及處理不支援的資料類型等情況。

請確保你在測試檔案中引入了需要測試的 FileDataServiceDbService,並且已經配置好了相應的測試模組。

直接貼上之後,確實直接可以測試,而且很詳盡的嘗試了各種組合!
https://ithelp.ithome.com.tw/upload/images/20231108/20135750x9gacNrviU.png

除了預設的service定義正確之外,另外定義了以下case

  1. 一開始的ID在A表找不到的情況,拋出NotFoundException 例外
  2. A表有找到,type=B,B表也有找到,並正確相加(3 + 5 = 8)
  3. A表有找到,type=B,但是B表沒找到,拋出NotFoundException 例外
  4. A表有找到,type=C,C表也有找到,且正確相乘 (3 * 5 = 15)
  5. A表有找到,type=C,但是C表沒找到,拋出NotFoundException 例外
  6. A表有找到,type=D,拋出UnprocessableEntityException例外

ChatGPT所提供的Case我個人認為已經足夠詳盡
又自行處理了 mockResolvedValueOnce 相關語法,舒舒服服

不然knex與mock的處理,可以說是相當的複雜難搞

Mock DataBase & mysql2

knex在做Mock的資料時,相當的複雜
必須順著程式中執行了哪些knex的方法鏈,然後逐步去做模擬的結果

因此另外加碼一下,針對不是使用knex,而是直接使用mysql2套件進行資料庫連線的話
對應的程式碼以及Jest單元測試應該是如何的

https://ithelp.ithome.com.tw/upload/images/20231108/20135750k1m9W2waI1.png

好的,如果你想要改用 mysql2.promise 來進行連線,首先我們來修改 DbService

以下是修改後的 DbService

// db/db.service.ts
import * as dotenv from 'dotenv';
import { createPool, Pool, PoolOptions } from 'mysql2/promise';

dotenv.config();

const poolOptions: PoolOptions = {
  host: process.env.MYSQL_HOST,
  user: process.env.MYSQL_USER,
  password: process.env.MYSQL_PASSWORD,
  database: 'mydatabase', // 替換為你的MySQL資料庫名稱
  connectionLimit: 10, // 根據需要調整連線池的大小
};

export class DbService {
  private pool: Pool;

  constructor() {
    this.pool = createPool(poolOptions);
  }

  async query(sql: string, values?: any[]): Promise<any> {
    try {
      const [rows, fields] = await this.pool.execute(sql, values);
      return rows;
    } catch (error) {
      throw error;
    }
  }
}

這裡我們使用了 mysql2/promise 提供的 createPool 方法來建立一個連線池,並提供了一個 query 方法用來執行 SQL 查詢。

接下來,我們來修改 FileDataService 使用新的 DbService

// file-data/file-data.service.ts
import { DbService } from '../db/db.service';
import {
  Injectable,
  NotFoundException,
  UnprocessableEntityException,
} from '@nestjs/common';

@Injectable()
export class FileDataService {
  constructor(private readonly dbService: DbService) {}

  async calcData(id: string) {
    const db = this.dbService;

    try {
      // 使用 mysql2.promise 執行 SQL 查詢
      const [aData] = await db.query('SELECT * FROM A WHERE id = ?', [id]);

      if (!aData) {
        throw new NotFoundException('找不到對應的資料');
      }

      if (aData.type === 'B') {
        // 使用 mysql2.promise 執行 SQL 查詢
        const [bData] = await db.query('SELECT * FROM B WHERE id = ?', [id]);

        if (!bData) {
          throw new NotFoundException('找不到對應的B表資料');
        }

        const result = aData.value + bData.value;
        return result;
      } else if (aData.type === 'C') {
        // 使用 mysql2.promise 執行 SQL 查詢
        const [cData] = await db.query('SELECT * FROM C WHERE id = ?', [id]);

        if (!cData) {
          throw new NotFoundException('找不到對應的C表資料');
        }

        const result = aData.value * cData.value;
        return result;
      } else {
        throw new UnprocessableEntityException('不支援的資料類型');
      }
    } catch (error) {
      throw error;
    }
  }
}

這裡我們將 FileDataService 修改成使用新的 DbService 進行 SQL 查詢。我們使用 db.query 方法來執行 SQL 查詢,並使用解構賦值來取得查詢結果。

請確保你在測試中引入了修改後的 DbServiceFileDataService,並進行適當的測試。如果有任何問題或需要進一步協助,請隨時告訴我!

接著要Jest單元測試的程式碼

https://ithelp.ithome.com.tw/upload/images/20231108/20135750vNv08UUMFO.png

當你使用 mysql2.promise 作為資料庫連線時,以下是修改後的 FileDataService 的 Jest 單元測試:

import { Test, TestingModule } from '@nestjs/testing';
import { FileDataService } from './file-data.service';
import { DbService } from '../db/db.service';
import { NotFoundException, UnprocessableEntityException } from '@nestjs/common';

describe('FileDataService', () => {
  let service: FileDataService;
  let dbService: DbService;
  let mockDb: any;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [FileDataService, DbService],
    }).compile();

    service = module.get<FileDataService>(FileDataService);
    dbService = module.get<DbService>(DbService);

    mockDb = {
      query: jest.fn(),
    };

    jest.spyOn(dbService, 'query').mockImplementation(mockDb.query);
  });

  it('should be defined', () => {
    expect(service).toBeDefined();
  });

  it('should throw NotFoundException when A data is not found', async () => {
    mockDb.query.mockResolvedValueOnce([]);

    try {
      await service.calcData('1');
    } catch (e) {
      expect(e).toBeInstanceOf(NotFoundException);
      expect(e.message).toBe('找不到對應的資料');
    }
  });

  it('should perform addition correctly for type B', async () => {
    mockDb.query.mockResolvedValueOnce([{ id: '1', type: 'B', value: 5 }]);
    mockDb.query.mockResolvedValueOnce([{ id: '1', value: 3 }]);

    const result = await service.calcData('1');
    expect(result).toBe(8);
  });

  it('should throw NotFoundException when B data is not found for type B', async () => {
    mockDb.query.mockResolvedValueOnce([{ id: '1', type: 'B', value: 5 }]);
    mockDb.query.mockResolvedValueOnce([]);

    try {
      await service.calcData('1');
    } catch (e) {
      expect(e).toBeInstanceOf(NotFoundException);
      expect(e.message).toBe('找不到對應的B表資料');
    }
  });

  it('should perform multiplication correctly for type C', async () => {
    mockDb.query.mockResolvedValueOnce([{ id: '1', type: 'C', value: 5 }]);
    mockDb.query.mockResolvedValueOnce([{ id: '1', value: 3 }]);

    const result = await service.calcData('1');
    expect(result).toBe(15);
  });

  it('should throw NotFoundException when C data is not found for type C', async () => {
    mockDb.query.mockResolvedValueOnce([{ id: '1', type: 'C', value: 5 }]);
    mockDb.query.mockResolvedValueOnce([]);

    try {
      await service.calcData('1');
    } catch (e) {
      expect(e).toBeInstanceOf(NotFoundException);
      expect(e.message).toBe('找不到對應的C表資料');
    }
  });

  it('should throw UnprocessableEntityException for unsupported type', async () => {
    mockDb.query.mockResolvedValueOnce([{ id: '1', type: 'D', value: 5 }]);

    try {
      await service.calcData('1');
    } catch (e) {
      expect(e).toBeInstanceOf(UnprocessableEntityException);
      expect(e.message).toBe('不支援的資料類型');
    }
  });
});

這些測試案例與之前的版本相似,但現在我們模擬了 DbServicequery 方法,以便測試 FileDataService 中的 SQL 查詢。請確保你已經正確配置了測試模組,並引入了修改後的 DbServiceFileDataService

如果有任何問題或需要進一步協助,請隨時告訴我!

一樣是貼了就可以跑
https://ithelp.ithome.com.tw/upload/images/20231108/20135750N1kIVoedLD.png

直接使用mysql2 在處理mock data的時候單純很多
不需要針對每個動作都去模擬步驟,程式中只會執行經過我們包裝的.query()
在處理mock data的時候就輕鬆很多

這個真的是只能看需求取捨

結語

本次的對話紀錄在這裡:
https://chat.openai.com/share/b568900c-4c43-420f-b2b8-37e6ba74e2fd

前後端同樣是使用Jest單元測試,但因為後端加入了模擬資料庫的部分
也就是將【Day31】ChatGPT請教教我:Jest 單元測試(下) - 完整語法&教學 後半的內容
進行實際運用,其實作與了解難度還是與普通的純函數測試不同

透過ChatGPT,可以快速的建立模板,也處理了和資料庫連線、模擬資料庫的複雜語法
我個人是覺得相當的方便,也方便新手快速學習、獲得相關的範例程式碼

尤其knex的資源和討論度偏少,stack overflow找半天沒找到相關範例
但搭配ChatGPT處理起來就輕鬆許多

雖然不得不說,ChatGPT偶爾會跑出沒辦法直接跑的程式碼
越冷門或是越複雜的內容,無法直接跑的程式碼出現機率會變高
只能再使用重新生成讓他多跑個幾次,裡面還是會出現可以使用的
或是經過簡單的修正就能使用的

還有丟入ChatGPT的程式碼不要過長也會得到比較好的結果
當他過長時,為了維護與閱讀成本,也該是考慮將程式碼單元切開會比較好些

以上!就是使用ChatGPT來生成Jest單元測試的成果
我是用ChatGPT撰寫Jest單元測試的前端工程師,我們明天見!

(我有準備好明天的內容,真的可以明天見了)


上一篇
【Day52】ChatGPT幫我完成工作:分散式詠唱來寫NodeJS & JavaScript!
下一篇
【Day54】ChatGPT幫我完成工作:不會python也能用python爬蟲做出IT邦幫忙自動發文神器
系列文
用ChatGPT詠唱來完成工作與點亮前後端技能樹56
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言