介绍
最近,我一直在为一个NestJS项目编写单元测试和E2E测试。这是我第一次为后端项目编写测试,我发现这个过程与我在前端测试的经验不同,使得开始时充满挑战。在看了一些例子之后,我对如何进行测试有了更清晰的了解,所以我打算写一篇文章来记录和分享我的学习,以帮助其他可能面临类似困惑的人。
此外,我还整理了一个演示项目,其中包含相关单元并已完成端到端测试,您可能会感兴趣。代码已上传至Github:https://github.com/woai3c/nestjs-demo。
单元测试和端到端测试之间的区别
单元测试和端到端测试都是软件测试的方法,但它们有不同的目标和范围。
单元测试涉及检查和验证软件中的最小可测试单元。例如,函数或方法可以被视为一个单元。在单元测试中,您为函数的各种输入提供预期输出并验证其操作的正确性。单元测试的目标是快速识别功能内的错误,并且它们易于编写和快速执行。
另一方面,E2E测试通常会模拟真实的用户场景来测试整个应用程序。例如,前端通常使用浏览器或无头浏览器进行测试,而后端则通过模拟 API 调用来进行测试。
在 NestJS 项目中,单元测试可能会评估特定服务或控制器的方法,例如验证 Users 模块中的更新方法是否正确更新用户。然而,端到端测试可能会检查完整的用户旅程,从创建新用户到更新密码再到最终删除用户,这涉及多个服务和控制器。
编写单元测试
为不涉及接口的实用函数或方法编写单元测试相对简单。您只需要考虑各种输入并编写相应的测试代码即可。然而,一旦接口发挥作用,情况就会变得更加复杂。我们以代码为例:
async validateUser( username: string, password: string, ): Promise<useraccountdto> { const entity = await this.usersService.findOne({ username }); if (!entity) { throw new UnauthorizedException('User not found'); } if (entity.lockUntil && entity.lockUntil > Date.now()) { const diffInSeconds = Math.round((entity.lockUntil - Date.now()) / 1000); let message = `The account is locked. Please try again in ${diffInSeconds} seconds.`; if (diffInSeconds > 60) { const diffInMinutes = Math.round(diffInSeconds / 60); message = `The account is locked. Please try again in ${diffInMinutes} minutes.`; } throw new UnauthorizedException(message); } const passwordMatch = bcrypt.compareSync(password, entity.password); if (!passwordMatch) { // $inc update to increase failedLoginAttempts const update = { $inc: { failedLoginAttempts: 1 }, }; // lock account when the third try is failed if (entity.failedLoginAttempts + 1 >= 3) { // $set update to lock the account for 5 minutes update['$set'] = { lockUntil: Date.now() + 5 * 60 * 1000 }; } await this.usersService.update(entity._id, update); throw new UnauthorizedException('Invalid password'); } // if validation is sucessful, then reset failedLoginAttempts and lockUntil if ( entity.failedLoginAttempts > 0 || (entity.lockUntil && entity.lockUntil > Date.now()) ) { await this.usersService.update(entity._id, { $set: { failedLoginAttempts: 0, lockUntil: null }, }); } return { userId: entity._id, username } as UserAccountDto; } </useraccountdto>
上面的代码是auth.service.ts文件中的validateUser方法,主要用于验证用户登录时输入的用户名和密码是否正确。它包含以下逻辑:
- 根据用户名检查用户是否存在;如果没有,则抛出401异常(404异常也是可行的)。
- 查看用户是否被锁定;如果是这样,则抛出 401 异常并附带相关消息。
- 对密码进行加密并与数据库中的密码进行比较;如果不正确,抛出401异常(连续3次登录失败将锁定账户5分钟)。
- 如果登录成功,则清除之前失败的登录尝试计数(如果适用),并将用户 ID 和用户名返回到下一阶段。
可以看出,validateUser方法包含四个处理逻辑,我们需要针对这四个点编写相应的单元测试代码,以保证整个validateUser函数运行正确。
第一个测试用例
当我们开始编写单元测试时,遇到一个问题:findOne方法需要与数据库交互,它通过username在数据库中查找对应的用户。但是,如果每个单元测试都必须与数据库进行交互,那么测试就会变得非常繁琐。因此,我们可以模拟假数据来实现这一点。
例如,假设我们注册了一个名为 woai3c 的用户。然后,在登录过程中,可以在 validateUser 方法中通过 constentity = wait this.usersService.findOne({ username }); 检索用户数据。只要这行代码能够返回想要的数据,就没有问题,即使没有数据库交互。我们可以通过模拟数据来实现这一点。现在,我们看一下 validateUser 方法的相关测试代码:
async validateUser( username: string, password: string, ): Promise<useraccountdto> { const entity = await this.usersService.findOne({ username }); if (!entity) { throw new UnauthorizedException('User not found'); } if (entity.lockUntil && entity.lockUntil > Date.now()) { const diffInSeconds = Math.round((entity.lockUntil - Date.now()) / 1000); let message = `The account is locked. Please try again in ${diffInSeconds} seconds.`; if (diffInSeconds > 60) { const diffInMinutes = Math.round(diffInSeconds / 60); message = `The account is locked. Please try again in ${diffInMinutes} minutes.`; } throw new UnauthorizedException(message); } const passwordMatch = bcrypt.compareSync(password, entity.password); if (!passwordMatch) { // $inc update to increase failedLoginAttempts const update = { $inc: { failedLoginAttempts: 1 }, }; // lock account when the third try is failed if (entity.failedLoginAttempts + 1 >= 3) { // $set update to lock the account for 5 minutes update['$set'] = { lockUntil: Date.now() + 5 * 60 * 1000 }; } await this.usersService.update(entity._id, update); throw new UnauthorizedException('Invalid password'); } // if validation is sucessful, then reset failedLoginAttempts and lockUntil if ( entity.failedLoginAttempts > 0 || (entity.lockUntil && entity.lockUntil > Date.now()) ) { await this.usersService.update(entity._id, { $set: { failedLoginAttempts: 0, lockUntil: null }, }); } return { userId: entity._id, username } as UserAccountDto; } </useraccountdto>
我们通过调用usersService的findOne方法来获取用户数据,所以我们需要在测试代码中模拟usersService的findOne方法:
import { Test } from '@nestjs/testing'; import { AuthService } from '@/modules/auth/auth.service'; import { UsersService } from '@/modules/users/users.service'; import { UnauthorizedException } from '@nestjs/common'; import { TEST_USER_NAME, TEST_USER_PASSWORD } from '@tests/constants'; describe('AuthService', () => { let authService: AuthService; // Use the actual AuthService type let usersService: Partial<record usersservice jest.mock>>; beforeEach(async () => { usersService = { findOne: jest.fn(), }; const module = await Test.createTestingModule({ providers: [ AuthService, { provide: UsersService, useValue: usersService, }, ], }).compile(); authService = module.get<authservice>(AuthService); }); describe('validateUser', () => { it('should throw an UnauthorizedException if user is not found', async () => { await expect( authService.validateUser(TEST_USER_NAME, TEST_USER_PASSWORD), ).rejects.toThrow(UnauthorizedException); }); // other tests... }); }); </authservice></record>
我们使用 jest.fn() 返回一个函数来替换真正的 usersService.findOne()。如果现在调用 usersService.findOne() ,将不会有返回值,因此第一个单元测试用例将通过:
beforeEach(async () => { usersService = { findOne: jest.fn(), // mock findOne method }; const module = await Test.createTestingModule({ providers: [ AuthService, // real AuthService, because we are testing its methods { provide: UsersService, // use mock usersService instead of real usersService useValue: usersService, }, ], }).compile(); authService = module.get<authservice>(AuthService); }); </authservice>
由于 constentity 中的 findOne = wait this.usersService.findOne({ username }); validateUser 方法是一个模拟的假函数,没有返回值,validateUser 方法中的第 2 到第 4 行代码可以执行:
it('should throw an UnauthorizedException if user is not found', async () => { await expect( authService.validateUser(TEST_USER_NAME, TEST_USER_PASSWORD), ).rejects.toThrow(UnauthorizedException); });
抛出 401 错误,符合预期。
第二个测试用例
validateUser方法中的第二个逻辑是判断用户是否被锁定,对应代码如下:
if (!entity) { throw new UnauthorizedException('User not found'); }
可以看到,如果用户数据中有锁定时间lockUntil,并且锁定结束时间大于当前时间,我们就可以判断当前账户被锁定。因此,我们需要使用 lockUntil 字段来模拟用户数据:
async validateUser( username: string, password: string, ): Promise<useraccountdto> { const entity = await this.usersService.findOne({ username }); if (!entity) { throw new UnauthorizedException('User not found'); } if (entity.lockUntil && entity.lockUntil > Date.now()) { const diffInSeconds = Math.round((entity.lockUntil - Date.now()) / 1000); let message = `The account is locked. Please try again in ${diffInSeconds} seconds.`; if (diffInSeconds > 60) { const diffInMinutes = Math.round(diffInSeconds / 60); message = `The account is locked. Please try again in ${diffInMinutes} minutes.`; } throw new UnauthorizedException(message); } const passwordMatch = bcrypt.compareSync(password, entity.password); if (!passwordMatch) { // $inc update to increase failedLoginAttempts const update = { $inc: { failedLoginAttempts: 1 }, }; // lock account when the third try is failed if (entity.failedLoginAttempts + 1 >= 3) { // $set update to lock the account for 5 minutes update['$set'] = { lockUntil: Date.now() + 5 * 60 * 1000 }; } await this.usersService.update(entity._id, update); throw new UnauthorizedException('Invalid password'); } // if validation is sucessful, then reset failedLoginAttempts and lockUntil if ( entity.failedLoginAttempts > 0 || (entity.lockUntil && entity.lockUntil > Date.now()) ) { await this.usersService.update(entity._id, { $set: { failedLoginAttempts: 0, lockUntil: null }, }); } return { userId: entity._id, username } as UserAccountDto; } </useraccountdto>
上面的测试代码中,首先定义了一个对象lockedUser,其中包含我们需要的lockUntil字段。然后,它被用作findOne的返回值,通过usersService.findOne.mockResolvedValueOnce(lockedUser);实现。这样,当执行 validateUser 方法时,其中的用户数据就是模拟数据,成功地让第二个测试用例通过。
单元测试覆盖率
单元测试覆盖率(代码覆盖率)是用于描述单元测试覆盖或测试了多少应用程序代码的指标。它通常以百分比表示,表示所有可能的代码路径中有多少已被测试用例覆盖。
单元测试覆盖率通常包括以下类型:
- 行覆盖率:测试覆盖了多少行代码。
- 函数覆盖率:测试覆盖了多少个函数或方法。
- 分支覆盖率:测试覆盖了多少个代码分支(例如 if/else 语句)。
- 语句覆盖率:测试覆盖了代码中的多少条语句。
单元测试覆盖率是衡量单元测试质量的重要指标,但不是唯一指标。高覆盖率可以帮助检测代码中的错误,但并不能保证代码的质量。覆盖率低可能意味着存在未经测试的代码,可能存在未检测到的错误。
下图显示了演示项目的单元测试覆盖率结果:
对于services、controller之类的文件,一般单元测试覆盖率越高越好,而对于module之类的文件则不需要写单元测试,也不可能写,因为没有意义。上图表示整个单元测试覆盖率的总体指标。如果要查看特定功能的测试覆盖率,可以打开项目根目录下的coverage/lcov-report/index.html文件。比如我想看看validateUser方法的具体测试情况:
可以看到,validateUser方法原来的单元测试覆盖率并不是100%,还有两行代码没有执行。不过没关系,不会影响四个关键处理节点,也不宜一维追求高测试覆盖率。
编写端到端测试
在单元测试中,我们演示了如何为 validateUser() 函数的每个功能编写单元测试,使用模拟数据来确保每个功能都可以被测试。在端到端测试中,我们需要模拟真实的用户场景,因此连接数据库进行测试是必要的。因此,我们将测试的 auth.service.ts 模块中的方法都与数据库交互。
auth模块主要包含以下功能:
- 注册
- 登录
- 令牌刷新
- 读取用户信息
- 更改密码
- 删除用户
端到端测试需要对这六个功能进行一一测试,从注册开始,到删除用户结束。在测试过程中,我们可以创建一个专门的测试用户来进行测试,完成后删除这个测试用户,以免在测试数据库中留下任何不必要的信息。
async validateUser( username: string, password: string, ): Promise<useraccountdto> { const entity = await this.usersService.findOne({ username }); if (!entity) { throw new UnauthorizedException('User not found'); } if (entity.lockUntil && entity.lockUntil > Date.now()) { const diffInSeconds = Math.round((entity.lockUntil - Date.now()) / 1000); let message = `The account is locked. Please try again in ${diffInSeconds} seconds.`; if (diffInSeconds > 60) { const diffInMinutes = Math.round(diffInSeconds / 60); message = `The account is locked. Please try again in ${diffInMinutes} minutes.`; } throw new UnauthorizedException(message); } const passwordMatch = bcrypt.compareSync(password, entity.password); if (!passwordMatch) { // $inc update to increase failedLoginAttempts const update = { $inc: { failedLoginAttempts: 1 }, }; // lock account when the third try is failed if (entity.failedLoginAttempts + 1 >= 3) { // $set update to lock the account for 5 minutes update['$set'] = { lockUntil: Date.now() + 5 * 60 * 1000 }; } await this.usersService.update(entity._id, update); throw new UnauthorizedException('Invalid password'); } // if validation is sucessful, then reset failedLoginAttempts and lockUntil if ( entity.failedLoginAttempts > 0 || (entity.lockUntil && entity.lockUntil > Date.now()) ) { await this.usersService.update(entity._id, { $set: { failedLoginAttempts: 0, lockUntil: null }, }); } return { userId: entity._id, username } as UserAccountDto; } </useraccountdto>
beforeAll钩子函数在所有测试开始之前运行,因此我们可以在这里注册一个测试帐户TEST_USER_NAME。 afterAll钩子函数在所有测试结束后运行,所以这里适合删除测试账号TEST_USER_NAME,也方便测试注册和删除功能。
在上一节的单元测试中,我们围绕 validateUser 方法编写了相关的单元测试。实际上,该方法是在登录时执行,以验证用户的帐号和密码是否正确。因此,本次e2E测试也将使用登录流程来演示如何编写e2E测试用例。
整个登录测试过程包括五个小测试:
import { Test } from '@nestjs/testing'; import { AuthService } from '@/modules/auth/auth.service'; import { UsersService } from '@/modules/users/users.service'; import { UnauthorizedException } from '@nestjs/common'; import { TEST_USER_NAME, TEST_USER_PASSWORD } from '@tests/constants'; describe('AuthService', () => { let authService: AuthService; // Use the actual AuthService type let usersService: Partial<record usersservice jest.mock>>; beforeEach(async () => { usersService = { findOne: jest.fn(), }; const module = await Test.createTestingModule({ providers: [ AuthService, { provide: UsersService, useValue: usersService, }, ], }).compile(); authService = module.get<authservice>(AuthService); }); describe('validateUser', () => { it('should throw an UnauthorizedException if user is not found', async () => { await expect( authService.validateUser(TEST_USER_NAME, TEST_USER_PASSWORD), ).rejects.toThrow(UnauthorizedException); }); // other tests... }); }); </authservice></record>
这五个测试如下:
- 登录成功,返回200
- 如果用户不存在,则抛出401异常
- 如果未提供密码或用户名,则抛出 400 异常
- 密码登录错误,抛出401异常
- 如果账户被锁定,抛出401异常
现在让我们开始编写 e2E 测试:
beforeEach(async () => { usersService = { findOne: jest.fn(), // mock findOne method }; const module = await Test.createTestingModule({ providers: [ AuthService, // real AuthService, because we are testing its methods { provide: UsersService, // use mock usersService instead of real usersService useValue: usersService, }, ], }).compile(); authService = module.get<authservice>(AuthService); }); </authservice>
编写 e2E 测试代码相对简单:只需调用接口,然后验证结果即可。比如登录测试成功,我们只需要验证返回结果是否为200即可。
前四个测试非常简单。现在我们来看一个稍微复杂一点的端到端测试,就是验证账户是否被锁定。
async validateUser( username: string, password: string, ): Promise<useraccountdto> { const entity = await this.usersService.findOne({ username }); if (!entity) { throw new UnauthorizedException('User not found'); } if (entity.lockUntil && entity.lockUntil > Date.now()) { const diffInSeconds = Math.round((entity.lockUntil - Date.now()) / 1000); let message = `The account is locked. Please try again in ${diffInSeconds} seconds.`; if (diffInSeconds > 60) { const diffInMinutes = Math.round(diffInSeconds / 60); message = `The account is locked. Please try again in ${diffInMinutes} minutes.`; } throw new UnauthorizedException(message); } const passwordMatch = bcrypt.compareSync(password, entity.password); if (!passwordMatch) { // $inc update to increase failedLoginAttempts const update = { $inc: { failedLoginAttempts: 1 }, }; // lock account when the third try is failed if (entity.failedLoginAttempts + 1 >= 3) { // $set update to lock the account for 5 minutes update['$set'] = { lockUntil: Date.now() + 5 * 60 * 1000 }; } await this.usersService.update(entity._id, update); throw new UnauthorizedException('Invalid password'); } // if validation is sucessful, then reset failedLoginAttempts and lockUntil if ( entity.failedLoginAttempts > 0 || (entity.lockUntil && entity.lockUntil > Date.now()) ) { await this.usersService.update(entity._id, { $set: { failedLoginAttempts: 0, lockUntil: null }, }); } return { userId: entity._id, username } as UserAccountDto; } </useraccountdto>
当用户连续3次登录失败时,账户将被锁定。因此,在本次测试中,我们不能使用测试帐户TEST_USER_NAME,因为如果测试成功,该帐户将被锁定,无法继续进行后续测试。我们需要注册另一个新用户TEST_USER_NAME2专门用于测试账户锁定,测试成功后删除该用户。所以,正如你所看到的,这个 e2E 测试的代码相当庞大,需要大量的设置和拆卸工作,但实际的测试代码只有这几行:
import { Test } from '@nestjs/testing'; import { AuthService } from '@/modules/auth/auth.service'; import { UsersService } from '@/modules/users/users.service'; import { UnauthorizedException } from '@nestjs/common'; import { TEST_USER_NAME, TEST_USER_PASSWORD } from '@tests/constants'; describe('AuthService', () => { let authService: AuthService; // Use the actual AuthService type let usersService: Partial<record usersservice jest.mock>>; beforeEach(async () => { usersService = { findOne: jest.fn(), }; const module = await Test.createTestingModule({ providers: [ AuthService, { provide: UsersService, useValue: usersService, }, ], }).compile(); authService = module.get<authservice>(AuthService); }); describe('validateUser', () => { it('should throw an UnauthorizedException if user is not found', async () => { await expect( authService.validateUser(TEST_USER_NAME, TEST_USER_PASSWORD), ).rejects.toThrow(UnauthorizedException); }); // other tests... }); }); </authservice></record>
编写 e2E 测试代码相对简单。您不需要考虑模拟数据或测试覆盖率。只要整个系统进程按预期运行就足够了。
是否编写测试
如果可能的话,我通常建议编写测试。这样做可以增强系统的健壮性、可维护性和开发效率。
增强系统稳健性
在编写代码时,我们通常会关注正常输入下的程序流程,以确保核心功能正常工作。然而,我们可能经常忽略一些边缘情况,例如异常输入。编写测试改变了这一点;它迫使您考虑如何处理这些情况并做出适当的响应,从而防止崩溃。可以说,编写测试间接提高了系统的健壮性。
增强可维护性
接手一个包含全面测试的新项目是非常令人愉快的。它们充当指南,帮助您快速了解各种功能。只需查看测试代码,您就可以轻松掌握每个函数的预期行为和边界条件,而无需逐行查看函数代码。
提升开发效率
想象一下,一个有一段时间没有更新的项目突然收到了新的需求。进行更改后,您可能会担心引入错误。如果没有测试,您将需要再次手动测试整个项目——浪费时间且效率低下。通过完整的测试,单个命令可以告诉您代码更改是否影响了现有功能。即使出现错误,也可以快速定位并解决。
什么时候不应该编写测试?
对于短期项目以及需求迭代非常快的项目,不建议编写测试。例如,某些用于活动的项目在活动结束后将毫无用处,不需要测试。另外,对于需求迭代非常快的项目,我说编写测试可以提高开发效率,但前提是函数迭代很慢。如果你刚刚完成的功能在一两天内发生变化,相关的测试代码就必须重写。所以,最好根本不写测试,而是依赖测试团队,因为写测试非常耗时,不值得付出努力。
结论
在详细解释了如何为NestJS项目编写单元测试和e2E测试之后,我仍然想重申一下测试的重要性。可以增强系统的健壮性、可维护性和开发效率。如果你没有机会编写测试,我建议你自己启动一个实践项目或者参与一些开源项目并为其贡献代码。开源项目通常有更严格的代码要求。贡献代码可能需要您编写新的测试用例或修改现有的测试用例。
参考资料
- NestJS:用于构建高效、可扩展的 Node.js 服务器端应用程序的框架。
- MongoDB:用于数据存储的 NoSQL 数据库。
- Jest:JavaScript 和 TypeScript 的测试框架。
- Supertest:用于测试 HTTP 服务器的库。
以上是如何为 NestJS 应用程序编写单元测试和 Etest的详细内容。更多信息请关注PHP中文网其他相关文章!

JavaScript字符串替换方法详解及常见问题解答 本文将探讨两种在JavaScript中替换字符串字符的方法:在JavaScript代码内部替换和在网页HTML内部替换。 在JavaScript代码内部替换字符串 最直接的方法是使用replace()方法: str = str.replace("find","replace"); 该方法仅替换第一个匹配项。要替换所有匹配项,需使用正则表达式并添加全局标志g: str = str.replace(/fi

因此,在这里,您准备好了解所有称为Ajax的东西。但是,到底是什么? AJAX一词是指用于创建动态,交互式Web内容的一系列宽松的技术。 Ajax一词,最初由Jesse J创造

10款趣味横生的jQuery游戏插件,让您的网站更具吸引力,提升用户粘性!虽然Flash仍然是开发休闲网页游戏的最佳软件,但jQuery也能创造出令人惊喜的效果,虽然无法与纯动作Flash游戏媲美,但在某些情况下,您也能在浏览器中获得意想不到的乐趣。 jQuery井字棋游戏 游戏编程的“Hello world”,现在有了jQuery版本。 源码 jQuery疯狂填词游戏 这是一个填空游戏,由于不知道单词的上下文,可能会产生一些古怪的结果。 源码 jQuery扫雷游戏

本教程演示了如何使用jQuery创建迷人的视差背景效果。 我们将构建一个带有分层图像的标题横幅,从而创造出令人惊叹的视觉深度。 更新的插件可与JQuery 1.6.4及更高版本一起使用。 下载

本文讨论了在浏览器中优化JavaScript性能的策略,重点是减少执行时间并最大程度地减少对页面负载速度的影响。

Matter.js是一个用JavaScript编写的2D刚体物理引擎。此库可以帮助您轻松地在浏览器中模拟2D物理。它提供了许多功能,例如创建刚体并为其分配质量、面积或密度等物理属性的能力。您还可以模拟不同类型的碰撞和力,例如重力摩擦力。 Matter.js支持所有主流浏览器。此外,它也适用于移动设备,因为它可以检测触摸并具有响应能力。所有这些功能都使其值得您投入时间学习如何使用该引擎,因为这样您就可以轻松创建基于物理的2D游戏或模拟。在本教程中,我将介绍此库的基础知识,包括其安装和用法,并提供一

本文演示了如何使用jQuery和ajax自动每5秒自动刷新DIV的内容。 该示例从RSS提要中获取并显示了最新的博客文章以及最后的刷新时间戳。 加载图像是选择


热AI工具

Undresser.AI Undress
人工智能驱动的应用程序,用于创建逼真的裸体照片

AI Clothes Remover
用于从照片中去除衣服的在线人工智能工具。

Undress AI Tool
免费脱衣服图片

Clothoff.io
AI脱衣机

AI Hentai Generator
免费生成ai无尽的。

热门文章

热工具

禅工作室 13.0.1
功能强大的PHP集成开发环境

MinGW - 适用于 Windows 的极简 GNU
这个项目正在迁移到osdn.net/projects/mingw的过程中,你可以继续在那里关注我们。MinGW:GNU编译器集合(GCC)的本地Windows移植版本,可自由分发的导入库和用于构建本地Windows应用程序的头文件;包括对MSVC运行时的扩展,以支持C99功能。MinGW的所有软件都可以在64位Windows平台上运行。

mPDF
mPDF是一个PHP库,可以从UTF-8编码的HTML生成PDF文件。原作者Ian Back编写mPDF以从他的网站上“即时”输出PDF文件,并处理不同的语言。与原始脚本如HTML2FPDF相比,它的速度较慢,并且在使用Unicode字体时生成的文件较大,但支持CSS样式等,并进行了大量增强。支持几乎所有语言,包括RTL(阿拉伯语和希伯来语)和CJK(中日韩)。支持嵌套的块级元素(如P、DIV),

记事本++7.3.1
好用且免费的代码编辑器

Atom编辑器mac版下载
最流行的的开源编辑器