<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
    <channel>
        
        <title>
            <![CDATA[ 测试 - freeCodeCamp.org ]]>
        </title>
        <description>
            <![CDATA[ freeCodeCamp 是一个免费学习编程的开发者社区，涵盖 Python、HTML、CSS、React、Vue、BootStrap、JSON 教程等，还有活跃的技术论坛和丰富的社区活动，在你学习编程和找工作时为你提供建议和帮助。 ]]>
        </description>
        <link>https://www.freecodecamp.org/chinese/news/</link>
        <image>
            <url>https://cdn.freecodecamp.org/universal/favicons/favicon.png</url>
            <title>
                <![CDATA[ 测试 - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/chinese/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Sat, 23 May 2026 19:22:18 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/chinese/news/tag/testing/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ 如何为 Nestjs 编写单元测试和 E2E 测试 ]]>
                </title>
                <description>
                    <![CDATA[ 前言 最近在给一个 nestjs 项目写单元测试（Unit Testing）和 e2e 测试（End-to-End Testing，端到端测试，简称 e2e 测试），这是我第一次给后端项目写测试，发现和之前给前端项目写测试还不太一样，导致在一开始写测试时感觉无从下手。后来在看了一些示例之后才想明白怎么写测试，所以打算写篇文章记录并分享一下，以帮助和我有相同困惑的人。 同时我也写了一个 demo 项目，相关的单元测试、e2e 测试都写好了，有兴趣可以看一下。代码已上传到 GitHub: nestjs-demo [https://link.segmentfault.com/?enc=HLCKHEcBvrrecUEHdZldwg%3D%3D.rqCa%2BxjnMJTtq32ACk7AhaKfYgiVglZx55Z7ivxfu3eX7EDOP%2Fzeot%2FIeXAwGxeG] 。 单元测试和 E2E 测试的区别 单元测试和 e2e 测试都是软件测试的方法，但它们的目标和范围有所不同。 单元测试是对软件中的最小可测试单元进行检查和验证。比如一个函数、一个方法都可以是一个单元。在单元 ]]>
                </description>
                <link>https://www.freecodecamp.org/chinese/news/how-to-write-unit-tests-and-e2e-tests-for-nestjs/</link>
                <guid isPermaLink="false">6690f01ddd1680043183e1d0</guid>
                
                    <category>
                        <![CDATA[ NextJS ]]>
                    </category>
                
                    <category>
                        <![CDATA[ 测试 ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ woai3c ]]>
                </dc:creator>
                <pubDate>Fri, 12 Jul 2024 02:50:00 +0000</pubDate>
                <media:content url="https://chinese.freecodecamp.org/news/content/images/2024/07/pexels-luis-gomes-546819.jpeg" medium="image" />
                <content:encoded>
                    <![CDATA[ <h2 id="-">前言</h2><p>最近在给一个 nestjs 项目写单元测试（Unit Testing）和 e2e 测试（End-to-End Testing，端到端测试，简称 e2e 测试），这是我第一次给后端项目写测试，发现和之前给前端项目写测试还不太一样，导致在一开始写测试时感觉无从下手。后来在看了一些示例之后才想明白怎么写测试，所以打算写篇文章记录并分享一下，以帮助和我有相同困惑的人。</p><p>同时我也写了一个 demo 项目，相关的单元测试、e2e 测试都写好了，有兴趣可以看一下。代码已上传到 GitHub: <a href="https://link.segmentfault.com/?enc=HLCKHEcBvrrecUEHdZldwg%3D%3D.rqCa%2BxjnMJTtq32ACk7AhaKfYgiVglZx55Z7ivxfu3eX7EDOP%2Fzeot%2FIeXAwGxeG" rel="nofollow">nestjs-demo</a>。</p><h2 id="-e2e-">单元测试和 E2E 测试的区别</h2><p>单元测试和 e2e 测试都是软件测试的方法，但它们的目标和范围有所不同。</p><p>单元测试是对软件中的最小可测试单元进行检查和验证。比如一个函数、一个方法都可以是一个单元。在单元测试中，你会对这个函数的各种输入给出预期的输出，并验证功能的正确性。单元测试的目标是快速发现函数内部的 bug，并且它们容易编写、快速执行。</p><p>而 e2e 测试通常通过模拟真实用户场景的方法来测试整个应用，例如前端通常使用浏览器或无头浏览器来进行测试，后端则是通过模拟对 API 的调用来进行测试。</p><p>在 nestjs 项目中，单元测试可能会测试某个服务（service）、某个控制器（controller）的一个方法，例如测试 Users 模块中的 <code>update</code> 方法是否能正确的更新一个用户。而一个 e2e 测试可能会测试一个完整的用户流程，如创建一个新用户，然后更新他们的密码，然后删除该用户。这涉及了多个服务和控制器。</p><h2 id="--1">编写单元测试</h2><p>为一个工具函数或者不涉及接口的方法编写单元测试，是非常简单的，你只需要考虑各种输入并编写相应的测试代码就可以了。但是一旦涉及到接口，那情况就复杂了。用代码来举例：</p><pre><code class="language-ts">async validateUser(
  username: string,
  password: string,
): Promise&lt;UserAccountDto&gt; {
  const entity = await this.usersService.findOne({ username });
  if (!entity) {
    throw new UnauthorizedException('User not found');
  }

  if (entity.lockUntil &amp;&amp; entity.lockUntil &gt; 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 &gt; 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 &gt;= 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 &gt; 0 ||
    (entity.lockUntil &amp;&amp; entity.lockUntil &gt; Date.now())
  ) {
    await this.usersService.update(entity._id, {
      $set: { failedLoginAttempts: 0, lockUntil: null },
    });
  }

  return { userId: entity._id, username } as UserAccountDto;
}
</code></pre><p>上面的代码是 <code>auth.service.ts</code> 文件里的一个方法 <code>validateUser</code>，主要用于验证登录时用户输入的账号密码是否正确。它包含的逻辑如下：</p><ol><li>根据 <code>username</code> 查看用户是否存在，如果不存在则抛出 401 异常（也可以是 404 异常）</li><li>查看用户是否被锁定，如果被锁定则抛出 401 异常和相关的提示文字</li><li>将 <code>password</code> 加密后和数据库中的密码进行对比，如果错误则抛出 401 异常（连续三次登录失败会被锁定账户 5 分钟）</li><li>如果登录成功，则将之前登录失败的计数记录进行清空（如果有）并返回用户 <code>id</code> 和 <code>username</code> 到下一阶段</li></ol><p>可以看到 <code>validateUser</code> 方法包含了 4 个处理逻辑，我们需要对这 4 点都编写对应的单元测试代码，以确定整个 <code>validateUser</code> 方法功能是正常的。</p><h3 id="--2">第一个测试用例</h3><p>在开始编写单元测试时，我们会遇到一个问题，<code>findOne</code> 方法需要和数据库进行交互，它要通过 <code>username</code> 查找数据库中是否存在对应的用户。但如果每一个单元测试都得和数据库进行交互，那测试起来会非常麻烦。所以可以通过 mock 假数据来实现这一点。</p><p>举例，假如我们已经注册了一个 <code>woai3c</code> 的用户，那么当用户登录时，在 <code>validateUser</code> 方法中能够通过 <code>const entity = await this.usersService.findOne({ username });</code> 拿到用户数据。所以只要确保这行代码能够返回想要的数据，即使不和数据库交互也是没有问题的。而这一点，我们能通过 mock 数据来实现。现在来看一下 <code>validateUser</code> 方法的相关测试代码：</p><pre><code class="language-ts">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', () =&gt; {
  let authService: AuthService; // Use the actual AuthService type
  let usersService: Partial&lt;Record&lt;keyof UsersService, jest.Mock&gt;&gt;;

  beforeEach(async () =&gt; {
    usersService = {
      findOne: jest.fn(),
    };

    const module = await Test.createTestingModule({
      providers: [
        AuthService,
        {
          provide: UsersService,
          useValue: usersService,
        },
      ],
    }).compile();

    authService = module.get&lt;AuthService&gt;(AuthService);
  });

  describe('validateUser', () =&gt; {
    it('should throw an UnauthorizedException if user is not found', async () =&gt; {
      await expect(
        authService.validateUser(TEST_USER_NAME, TEST_USER_PASSWORD),
      ).rejects.toThrow(UnauthorizedException);
    });

    // other tests...
  });
});
</code></pre><p>我们通过调用 <code>usersService</code> 的 <code>fineOne</code> 方法来拿到用户数据，所以需要在测试代码中 mock <code>usersService</code> 的 <code>fineOne</code> 方法：</p><pre><code class="language-ts"> beforeEach(async () =&gt; {
    usersService = {
      findOne: jest.fn(), // 在这里 mock findOne 方法
    };

    const module = await Test.createTestingModule({
      providers: [
        AuthService, // 真实的 AuthService，因为我们要对它的方法进行测试
        {
          provide: UsersService, // 用 mock 的 usersService 代替真实的 usersService 
          useValue: usersService,
        },
      ],
    }).compile();

    authService = module.get&lt;AuthService&gt;(AuthService);
  });
</code></pre><p>通过使用 <code>jest.fn()</code> 返回一个函数来代替真实的 <code>usersService.findOne()</code>。如果这时调用 <code>usersService.findOne()</code> 将不会有任何返回值，所以第一个单元测试用例就能通过了：</p><pre><code class="language-ts">it('should throw an UnauthorizedException if user is not found', async () =&gt; {
  await expect(
    authService.validateUser(TEST_USER_NAME, TEST_USER_PASSWORD),
  ).rejects.toThrow(UnauthorizedException);
});
</code></pre><p>因为在 <code>validateUser</code> 方法中调用 <code>const entity = await this.usersService.findOne({ username });</code> 的 <code>findOne</code> 是 mock 的假函数，没有返回值，所以 <code>validateUser</code> 方法中的第 2-4 行代码就能执行到了：</p><pre><code class="language-ts">if (!entity) {
  throw new UnauthorizedException('User not found');
}
</code></pre><p>抛出 401 错误，符合预期。</p><h3 id="--3">第二个测试用例</h3><p><code>validateUser</code> 方法中的第二个处理逻辑是判断用户是否锁定，对应的代码如下：</p><pre><code class="language-ts">if (entity.lockUntil &amp;&amp; entity.lockUntil &gt; 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 &gt; 60) {
    const diffInMinutes = Math.round(diffInSeconds / 60);
    message = `The account is locked. Please try again in ${diffInMinutes} minutes.`;
  }

  throw new UnauthorizedException(message);
}
</code></pre><p>可以看到如果用户数据里有锁定时间 <code>lockUntil</code> 并且锁定结束时间大于当前时间就可以判断当前账户处于锁定状态。所以需要 mock 一个具有 <code>lockUntil</code> 字段的用户数据：</p><pre><code class="language-ts">it('should throw an UnauthorizedException if the account is locked', async () =&gt; {
  const lockedUser = {
    _id: TEST_USER_ID,
    username: TEST_USER_NAME,
    password: TEST_USER_PASSWORD,
    lockUntil: Date.now() + 1000 * 60 * 5, // The account is locked for 5 minutes
  };

  usersService.findOne.mockResolvedValueOnce(lockedUser);

  await expect(
    authService.validateUser(TEST_USER_NAME, TEST_USER_PASSWORD),
  ).rejects.toThrow(UnauthorizedException);
});
</code></pre><p>在上面的测试代码里，先定义了一个对象 <code>lockedUser</code>，这个对象里有我们想要的 <code>lockUntil</code> 字段，然后将它作为 <code>findOne</code> 的返回值，这通过 <code>usersService.findOne.mockResolvedValueOnce(lockedUser);</code> 实现。然后 <code>validateUser</code> 方法执行时，里面的用户数据就是 mock 出来的数据了，从而成功让第二个测试用例通过。</p><h3 id="--4">单元测试覆盖率</h3><p>剩下的两个测试用例就不写了，原理都是一样的。如果剩下的两个测试不写，那么这个 <code>validateUser</code> 方法的单元测试覆盖率会是 50%，如果 4 个测试用例都写完了，那么 <code>validateUser</code> 方法的单元测试覆盖率将达到 100%。</p><p>单元测试覆盖率（Code Coverage）是一个度量，用于描述应用程序代码有多少被单元测试覆盖或测试过。它通常表示为百分比，表示在所有可能的代码路径中，有多少被测试用例覆盖。</p><p>单元测试覆盖率通常包括以下几种类型：</p><ul><li>行覆盖率（Lines）：测试覆盖了多少代码行。</li><li>函数覆盖率（Funcs）：测试覆盖了多少函数或方法。</li><li>分支覆盖率（Branch）：测试覆盖了多少代码分支（例如，<code>if/else</code> 语句）。</li><li>语句覆盖率（Stmts）：测试覆盖了多少代码语句。</li></ul><p>单元测试覆盖率是衡量单元测试质量的一个重要指标，但并不是唯一的指标。高的覆盖率可以帮助检测代码中的错误，但并不能保证代码的质量。覆盖率低可能意味着有未被测试的代码，可能存在未被发现的错误。</p><p>下图是 demo 项目的单元测试覆盖率结果：</p><figure class="kg-card kg-image-card"><img src="https://chinese.freecodecamp.org/news/content/images/2024/07/image-3.png" class="kg-image" alt="image-3" srcset="https://chinese.freecodecamp.org/news/content/images/size/w600/2024/07/image-3.png 600w, https://chinese.freecodecamp.org/news/content/images/2024/07/image-3.png 655w" width="655" height="566" loading="lazy"></figure><p>像 service 和 controller 之类的文件，单元测试覆盖率一般尽量高点比较好，而像 module 这种文件就没有必要写单元测试了，也没法写，没有意义。上面的图片表示的是整个单元测试覆盖率的总体指标，如果你想查看某个函数的测试覆盖率，可以打开项目根目录下的 <code>coverage/lcov-report/index.html</code> 文件进行查看。例如我想查看 <code>validateUser</code> 方法具体的测试情况：</p><figure class="kg-card kg-image-card"><img src="https://chinese.freecodecamp.org/news/content/images/2024/07/image-4.png" class="kg-image" alt="image-4" srcset="https://chinese.freecodecamp.org/news/content/images/size/w600/2024/07/image-4.png 600w, https://chinese.freecodecamp.org/news/content/images/2024/07/image-4.png 711w" width="711" height="780" loading="lazy"></figure><p>可以看到原来 <code>validateUser</code> 方法的单元测试覆盖率并不是 100%，还是有两行代码没有执行到，不过也无所谓了，不影响 4 个关键的处理节点，不要片面的追求高测试覆盖率。</p><h2 id="-e2e--1">编写E2E 测试</h2><p>在单元测试中我们展示了如何为 <code>validateUser()</code> 的每一个功能点编写单元测试，并且使用了 mock 数据的方法来确保每个功能点都能够被测试到。而在 e2e 测试中，我们需要模拟真实的用户场景，所以要连接数据库来进行测试。因此，这次测试的 <code>auth.service.ts</code> 模块里的方法都会和数据库进行交互。</p><p><code>auth</code> 模块主要有以下几个功能：</p><ul><li>注册</li><li>登录</li><li>刷新 token</li><li>读取用户信息</li><li>修改密码</li><li>删除用户</li></ul><p>e2e 测试需要将这六个功能都测试一遍，从<code>注册</code>开始，到<code>删除用户</code>结束。在测试时，我们可以建一个专门的测试用户来进行测试，测试完成后再删除这个测试用户，这样就不会在测试数据库中留下无用的信息了。</p><pre><code class="language-ts">beforeAll(async () =&gt; {
  const moduleFixture: TestingModule = await Test.createTestingModule({
    imports: [AppModule],
  }).compile()

  app = moduleFixture.createNestApplication()
  await app.init()

  // 执行登录以获取令牌
  const response = await request(app.getHttpServer())
    .post('/auth/register')
    .send({ username: TEST_USER_NAME, password: TEST_USER_PASSWORD })
    .expect(201)

  accessToken = response.body.access_token
  refreshToken = response.body.refresh_token
})

afterAll(async () =&gt; {
  await request(app.getHttpServer())
    .delete('/auth/delete-user')
    .set('Authorization', `Bearer ${accessToken}`)
    .expect(200)

  await app.close()
})
</code></pre><p><code>beforeAll</code> 钩子函数将在所有测试开始之前执行，所以我们可以在这里注册一个测试账号 <code>TEST_USER_NAME</code>。<code>afterAll</code> 钩子函数将在所有测试结束之后执行，所以在这删除测试账号 <code>TEST_USER_NAME</code> 是比较合适的，还能顺便对注册和删除两个功能进行测试。</p><p>在上一节的单元测试中，我们编写了关于 <code>validateUser</code> 方法的相关单元测试。其实这个方法是在登录时执行的，用于验证用户账号密码是否正确。所以这一次的 e2e 测试也将使用登录流程来展示如何编写 e2e 测试用例。</p><p>整个登录测试流程总共包含了五个小测试：</p><pre><code class="language-ts">describe('login', () =&gt; {
    it('/auth/login (POST)', () =&gt; {
      // ...
    })

    it('/auth/login (POST) with user not found', () =&gt; {
      // ...
    })

    it('/auth/login (POST) without username or password', async () =&gt; {
      // ...
    })

    it('/auth/login (POST) with invalid password', () =&gt; {
      // ...
    })

    it('/auth/login (POST) account lock after multiple failed attempts', async () =&gt; {
      // ...
    })
  })
</code></pre><p>这五个测试分别是：</p><ol><li>登录成功，返回 200</li><li>如果用户不存在，抛出 401 异常</li><li>如果不提供密码或用户名，抛出 400 异常</li><li>使用错误密码登录，抛出 401 异常</li><li>如果账户被锁定，抛出 401 异常</li></ol><p>现在我们开始编写 e2e 测试：</p><pre><code class="language-ts">// 登录成功
it('/auth/login (POST)', () =&gt; {
  return request(app.getHttpServer())
    .post('/auth/login')
    .send({ username: TEST_USER_NAME, password: TEST_USER_PASSWORD })
    .expect(200)
})

// 如果用户不存在，应该抛出 401 异常
it('/auth/login (POST) with user not found', () =&gt; {
  return request(app.getHttpServer())
    .post('/auth/login')
    .send({ username: TEST_USER_NAME2, password: TEST_USER_PASSWORD })
    .expect(401) // Expect an unauthorized error
})
</code></pre><p>e2e 的测试代码写起来比较简单，直接调用接口，然后验证结果就可以了。比如登录成功测试，我们只要验证返回结果是否是 200 即可。</p><p>前面四个测试都比较简单，现在我们看一个稍微复杂点的 e2e 测试，即验证账户是否被锁定。</p><pre><code class="language-ts">it('/auth/login (POST) account lock after multiple failed attempts', async () =&gt; {
  const moduleFixture: TestingModule = await Test.createTestingModule({
    imports: [AppModule],
  }).compile()

  const app = moduleFixture.createNestApplication()
  await app.init()

  const registerResponse = await request(app.getHttpServer())
    .post('/auth/register')
    .send({ username: TEST_USER_NAME2, password: TEST_USER_PASSWORD })

  const accessToken = registerResponse.body.access_token
  const maxLoginAttempts = 3 // lock user when the third try is failed

  for (let i = 0; i &lt; maxLoginAttempts; i++) {
    await request(app.getHttpServer())
      .post('/auth/login')
      .send({ username: TEST_USER_NAME2, password: 'InvalidPassword' })
  }

  // The account is locked after the third failed login attempt
  await request(app.getHttpServer())
    .post('/auth/login')
    .send({ username: TEST_USER_NAME2, password: TEST_USER_PASSWORD })
    .then((res) =&gt; {
      expect(res.body.message).toContain(
        'The account is locked. Please try again in 5 minutes.',
      )
    })

  await request(app.getHttpServer())
    .delete('/auth/delete-user')
    .set('Authorization', `Bearer ${accessToken}`)

  await app.close()
})
</code></pre><p><strong>当用户连续三次登录失败的时候，账户就会被锁定</strong>。所以在这个测试里，我们不能使用测试账号 <code>TEST_USER_NAME</code>，因为测试成功的话这个账户就会被锁定，无法继续进行下面的测试了。我们需要再注册一个新用户 <code>TEST_USER_NAME2</code>，专门用来测试账户锁定，测试成功后再删除这个用户。所以你可以看到这个 e2e 测试的代码非常多，需要做大量的前置、后置工作，其实真正的测试代码就这几行：</p><pre><code class="language-ts">// 连续三次登录
for (let i = 0; i &lt; maxLoginAttempts; i++) {
  await request(app.getHttpServer())
    .post('/auth/login')
    .send({ username: TEST_USER_NAME2, password: 'InvalidPassword' })
}

// 测试账号是否被锁定
await request(app.getHttpServer())
  .post('/auth/login')
  .send({ username: TEST_USER_NAME2, password: TEST_USER_PASSWORD })
  .then((res) =&gt; {
    expect(res.body.message).toContain(
      'The account is locked. Please try again in 5 minutes.',
    )
  })
</code></pre><p>可以看到编写 e2e 测试代码还是相对比较简单的，不需要考虑 mock 数据，不需要考虑测试覆盖率，只要整个系统流程的运转情况符合预期就可以了。</p><h2 id="--5">应不应该写测试</h2><p>如果有条件的话，我是比较建议大家写测试的。因为写测试可以提高系统的健壮性、可维护性和开发效率。</p><h3 id="--6">提高系统健壮性</h3><p>我们一般编写代码时，会关注于正常输入下的程序流程，确保核心功能正常运作。但是一些边缘情况，比如异常的输入，这些我们可能会经常忽略掉。但当我们开始编写测试时，情况就不一样了，这会逼迫你去考虑如何处理并提供相应的反馈，从而避免程序崩溃。可以说写测试实际上是在<strong>间接</strong>地提高系统健壮性。</p><h3 id="--7">提高可维护性</h3><p>当你接手一个新项目时，如果项目包含完善的测试，那将会是一件很幸福的事情。它们就像是项目的指南，帮你快速把握各个功能点。只看测试代码就能够轻松地了解每个功能的预期行为和边界条件，而不用你逐行的去查看每个功能的代码。</p><h3 id="--8">提高开发效率</h3><p>想象一下，一个长时间未更新的项目突然接到了新需求。改了代码后，你可能会担心引入 bug，如果没有测试，那就需要重新手动测试整个项目——浪费时间，效率低下。而有了完整的测试，一条命令就能得知代码更改有没有影响现有功能。即使出错了，也能够快速定位，找到问题点。</p><h3 id="--9">什么时候不建议写测试？</h3><p><strong>短期项目</strong>、<strong>需求迭代非常快的项目</strong>不建议写测试。比如某些活动项目，活动结束就没用了，这种项目就不需要写测试。另外，需求迭代非常快的项目也不要写测试，我刚才说写测试能提高开发效率是有前提条件的，就是<strong>功能迭代比较慢的情况下，写测试才能提高开发效率</strong>。如果你的功能今天刚写完，隔一两天就需求变更了要改功能，那相关的测试代码都得重写。所以干脆就别写了，靠团队里的测试人员测试就行了，因为写测试是非常耗时间的，没必要自讨苦吃。</p><p>根据我的经验来看，国内的绝大多数项目（尤其是政企类项目）都是没有必要写测试的，因为需求迭代太快，还老是推翻之前的需求，代码都得加班写，那有闲情逸致写测试。</p><h2 id="--10">总结</h2><p>在细致地讲解了如何为 Nestjs 项目编写单元测试及 e2e 测试之后，我还是想重申一下测试的重要性，它能够提高系统的健壮性、可维护性和开发效率。如果没有机会写测试，我建议大家可以自己搞个练习项目来写，或者说参加一些开源项目，给这些项目贡献代码，因为开源项目对于代码要求一般都比较严格。贡献代码可能需要编写新的测试用例或修改现有的测试用例。</p><p>最后，再推荐一下我的其他文章，如果你有兴趣，不妨一读：</p><ul><li><a href="https://www.freecodecamp.org/chinese/news/search?query=%E5%85%A5%E9%97%A8%E5%89%8D%E7%AB%AF%E5%B7%A5%E7%A8%8B">带你入门前端工程</a></li><li><a href="https://www.freecodecamp.org/chinese/news/browser-rendering-engine/">从零开始实现一个玩具版浏览器渲染引擎</a></li><li><a href="https://www.freecodecamp.org/chinese/news/build-a-micro-frontend-framework/">手把手教你写一个简易的微前端框架</a></li><li><a href="https://www.freecodecamp.org/chinese/news/tech-analysis-of-front-end-monitoring-sdk/">前端监控 SDK 的一些技术要点原理分析</a></li><li><a href="https://www.freecodecamp.org/chinese/news/visual-drag-and-drop-component-library/">可视化拖拽组件库一些技术要点原理分析</a></li><li><a href="https://www.freecodecamp.org/chinese/news/visual-drag-and-drop-component-library-part-2/">可视化拖拽组件库一些技术要点原理分析（二）</a></li><li><a href="https://www.freecodecamp.org/chinese/news/visual-drag-and-drop-component-library-part-3/">可视化拖拽组件库一些技术要点原理分析（三）</a></li><li><a href="https://www.freecodecamp.org/chinese/news/visual-drag-and-drop-component-library-part-4/">可视化拖拽组件库一些技术要点原理分析（四）</a></li><li><a href="https://www.freecodecamp.org/chinese/news/exploration-and-practices-for-low-code-and-llms/">低代码与大语言模型的探索实践</a></li><li><a href="https://www.freecodecamp.org/chinese/news/improve-front-end-performance/">前端性能优化 24 条建议（2020）</a></li><li><a href="https://www.freecodecamp.org/chinese/news/create-a-scaffold/">手把手教你写一个脚手架</a></li><li><a href="https://www.freecodecamp.org/chinese/news/create-a-scaffold-2/">手把手教你写一个脚手架（二）</a></li></ul><h3 id="--11">参考资料</h3><ul><li><a href="https://nestjs.com/">NestJS</a>: A framework for building efficient, scalable Node.js server-side applications.</li><li><a href="https://www.mongodb.com/">MongoDB</a>: A NoSQL database used for data storage.</li><li><a href="https://jestjs.io/">Jest</a>: A testing framework for JavaScript and TypeScript.</li><li><a href="https://github.com/ladjs/supertest">Supertest</a>: A library for testing HTTP servers.</li></ul> ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ 如何使用 TDD 和 React 测试库构建健壮的 React 应用程序 ]]>
                </title>
                <description>
                    <![CDATA[ 在我开始学习 React 时，我曾经挣扎于如何以一种既有用又直观的方式测试我的 web 应用。每次我想要测试一个组件时，我都会使用 Enzyme [http://airbnb.io/enzyme/docs/api/] 和 Jest [https://facebook.github.io/jest/]  进行表层渲染。 当然，我绝对是在滥用快照测试功能。 好吧，至少我写了一个测试，对吧？ 你可能在某个地方听说过，编写单元测试和集成测试将提高你编写的软件的质量。另一方面，糟糕的测试会滋生虚假的信心。 最近，我参加了 workshop.me [https://workshop.me/] 上由 Kent C. Dodds [https://www.freecodecamp.org/news/how-to-build-sturdy-react-apps-with-tdd-and-the-react-testing-library-47ad3c5c8e47/undefined]  主持的一个研讨会，他教我们如何为 React 应用编写更好的集成测试。 他还指导我们使用他的新的测试库 [h ]]>
                </description>
                <link>https://www.freecodecamp.org/chinese/news/how-to-build-sturdy-react-apps-with-tdd-and-the-react-testing-library/</link>
                <guid isPermaLink="false">64cba65db05f0606c70fb7c9</guid>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ React ]]>
                    </category>
                
                    <category>
                        <![CDATA[ 测试 ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ herosql ]]>
                </dc:creator>
                <pubDate>Wed, 02 Aug 2023 13:10:00 +0000</pubDate>
                <media:content url="https://chinese.freecodecamp.org/news/content/images/2023/08/1691068282139.jpg" medium="image" />
                <content:encoded>
                    <![CDATA[ <p data-test-label="translation-intro">
        <strong>原文：</strong> <a href="https://www.freecodecamp.org/news/how-to-build-sturdy-react-apps-with-tdd-and-the-react-testing-library-47ad3c5c8e47/" target="_blank" rel="noopener noreferrer" data-test-label="original-article-link">How to build sturdy React apps with TDD and the React Testing Library</a>
      </p><!--kg-card-begin: markdown--><p>在我开始学习 React 时，我曾经挣扎于如何以一种既有用又直观的方式测试我的 web 应用。每次我想要测试一个组件时，我都会使用 <a href="http://airbnb.io/enzyme/docs/api/">Enzyme</a> 和 <a href="https://facebook.github.io/jest/">Jest</a> 进行表层渲染。</p>
<p>当然，我绝对是在滥用快照测试功能。</p>
<p>好吧，至少我写了一个测试，对吧？</p>
<p>你可能在某个地方听说过，编写单元测试和集成测试将提高你编写的软件的质量。另一方面，糟糕的测试会滋生虚假的信心。</p>
<p>最近，我参加了 <a href="https://workshop.me/">workshop.me</a> 上由 <a href="https://www.freecodecamp.org/news/how-to-build-sturdy-react-apps-with-tdd-and-the-react-testing-library-47ad3c5c8e47/undefined">Kent C. Dodds</a> 主持的一个研讨会，他教我们如何为 React 应用编写更好的集成测试。</p>
<p>他还指导我们使用他的<a href="https://github.com/kentcdodds/react-testing-library">新的测试库</a>，以强调以用户可能遇到的相同方式测试应用程序。</p>
<p>在本文中，我们将通过创建评论反馈来学习如何在构建稳定的 React 应用程序中运用 TDD。当然，这个过程适用于几乎所有的软件开发，而不仅仅是 React 或 JavaScript 应用。</p>
<h3 id="">开始</h3>
<p>我们将首先运行 <code>create-react-app</code> 并安装依赖项。我的假设是，如果你正在阅读关于测试应用程序的文章，你可能已经熟悉安装和启动 JavaScript 项目。在这里，我将使用 yarn 而不是 npm。</p>
<pre><code class="language-plain">create-react-app comment-feed
</code></pre>
<pre><code class="language-plain">cd comment-feed
</code></pre>
<pre><code class="language-plain">yarn
</code></pre>
<p>首先，我们将删除 src 目录中除 index.js 之外的所有文件。然后，在 src 文件夹内部，创建一个名为 components 的新文件夹和另一个名为 containers 的文件夹。</p>
<p>在测试工具方面，我将使用 Kent 的 <a href="https://github.com/kentcdodds/react-testing-library">React 测试库</a> 构建此应用程序。它是一款轻量级的测试工具，鼓励开发者以实际使用时相同的方式测试他们的应用程序。</p>
<p>与 Enzyme 一样，它导出一个渲染函数，但这个渲染函数始终对你的组件进行完整挂载。它导出辅助方法，允许你通过标签、文本甚至测试 ID 来定位元素。Enzyme 也通过其 <code>mount</code> API 实现了这一点，但它创建的抽象实际上提供了更多选项，其中许多选项允许你摆脱测试实现细节。</p>
<p>我们不想要测试所有的实现细节。我们想要渲染一个组件，看看当点击或更改 UI 上的某些内容时是否会发生正确的事情。就是这样！不再直接检查 props 、state 或类名。</p>
<p>现在让我们安装它们并开始工作。</p>
<pre><code class="language-plain">yarn add react-testing-library
</code></pre>
<h3 id="tdd">通过 TDD 构建评论反馈</h3>
<p>让我们以 TDD 风格进行第一个组件的开发。启动你的测试运行器。</p>
<pre><code class="language-plain">yarn test --watch
</code></pre>
<p>在 <code>containers</code> 文件夹中，我们将添加一个名为 CommentFeed.js 的文件。与之相伴的，添加一个名为 CommentFeed.test.js 的文件。在第一个测试中，让我们验证用户是否可以创建评论。太早了？好吧，既然我们还没有任何代码，我们将从一个较小的测试开始。检查一下是否可以渲染反馈。</p>
<h3 id="reacttestinglibrary">关于 react-testing-library 的一些说明</h3>
<p>首先，让我们注意这里的渲染函数。它类似于 <code>react-dom</code> 将组件渲染到 DOM 的方式，但它返回一个对象，我们可以解构该对象以获得一些实用的测试辅助工具。在这种情况下，我们得到 <code>queryByText</code>，它会返回我们期望在 DOM 上看到的 HTML 元素。</p>
<p><a href="https://github.com/kentcdodds/react-testing-library#faq">React 测试库文档</a>提供了一个层次结构，帮助你决定使用哪个查询或获取方法。通常，顺序如下：</p>
<ul>
<li><code>getByLabelText</code>（表单输入）</li>
<li><code>getByPlaceholderText</code>（仅在你的输入没有标签时使用 — 很少使用！）</li>
<li><code>getByText</code>（按钮和标题）</li>
<li><code>getByAltText</code>（图片）</li>
<li><code>getByTestId</code>（用于动态文本或其他你想要测试的奇怪元素）</li>
</ul>
<p>每个方法都有一个相关的 <code>queryByFoo</code>，除了在找不到元素时不会使测试失败之外，它们的功能相同。如果你只是测试元素的<strong>存在</strong>，请使用这些方法。</p>
<p>如果这些方法都无法满足你的需求，<code>render</code> 方法还返回映射到 <code>container</code> 属性的 DOM 元素，因此你可以像 <code>container.querySelector(‘body #root’)</code> 这样使用它。</p>
<h3 id="">首次实现代码</h3>
<p>现在，实现看起来相当简单。我们只需要确保“评论反馈”是一个组件。</p>
<p>它可能会更糟糕 - 我的意思是，我在编写整篇文章的过程中，还要编写组件的样式。幸运的是，测试并不太关心样式，所以我们可以专注于应用逻辑。</p>
<p>接下来的测试将验证我们是否可以渲染评论。但是我们甚至还没有任何评论，所以也添加一个组件，在测试之后添加。</p>
<p>我还将创建一个 props 对象来存储我们可能在这些测试中重用的数据。</p>
<p>在这种情况下，我正在检查评论的数量是否等于传入 CommentFeed 的项目数量。这是无关紧要的，但测试失败给了我们创建 Comment.js 文件的机会。</p>
<p>这为我们的测试套件亮起了绿灯，我们就可以放心地继续了。向 TDD 致敬。当我们给它一个空数组时，它当然会工作。但是，如果我们给它一些真实的对象，会发生什么呢？</p>
<p>我们必须更新实现以实际渲染内容。现在我们知道要去哪里，这很简单，对吧？</p>
<p>看看这个，我们的测试再次通过了。这是一个美妙的截图。</p>
<p><img src="https://cdn-media-1.freecodecamp.org/images/1*vGkFKnUkA9ms5PbaOWoQ_A.png" alt="1*vGkFKnUkA9ms5PbaOWoQ_A" width="600" height="400" loading="lazy"></p>
<p>注意我从未说过我们应该用 <code>yarn start</code> 启动程序吗？我们继续保持这种方式一段时间。关键是，你必须用心去感受代码。</p>
<p>样式只是外部表现 - 重要的是内部的东西。</p>
<p>以防你想启动应用程序，将 index.js 更新为以下内容：</p>
<h3 id="">添加评论表单</h3>
<p>这是事情开始变得更有趣的地方。这是我们从困倦地检查 DOM 节点的存在到实际使用它并<strong>验证行为</strong>的地方。所有其他的东西都是热身。</p>
<p>让我们从描述我想要的表单开始。它应该：</p>
<ul>
<li>包含一个作者的文本输入</li>
<li>包含一个评论条目的文本输入</li>
<li>有一个提交按钮</li>
<li>最终调用 API 或处理创建和存储评论的其他服务。</li>
</ul>
<p>我们可以在一个集成测试中完成这个列表。对于之前的测试用例，我们进行得相当缓慢，但现在我们要加快速度，一举完成。</p>
<p>注意我们的测试套件是如何发展的吗？我们从在各自的测试用例中硬编码 props 转变为为它们创建一个工厂。</p>
<h4 id="">准备、执行、断言</h4>
<p>以下集成测试可以分为三个部分：准备，执行和断言。</p>
<ul>
<li><strong>准备：</strong> 为测试用例创建props和其他测试用例</li>
<li><strong>执行：</strong> 模拟对元素的更改，例如文本输入或按钮点击</li>
<li><strong>断言：</strong>  断言所需的函数被正确次数调用，并使用正确的参数</li>
</ul>
<p>关于代码，我们做了一些假设，比如我们的标签命名或我们将拥有一个 <code>createComment</code> prop。</p>
<p>在查找输入时，我们希望尝试通过它们的标签找到它们。这样在构建应用程序时，可以优先考虑可访问性。使用 <code>container.querySelector</code> 是获取表单的最简单方法。</p>
<p>接下来，我们必须为输入分配新值，并模拟更改以更新它们的状态。这一步可能感觉有点奇怪，因为通常一次输入一个字符，为每个新字符更新组件的状态。</p>
<p>这个测试的行为更像是复制/粘贴的行为，从空字符串变为 “Socrates” 。目前没有中断问题，但我们可能需要注意一下，以防以后出现问题。</p>
<p>在提交表单后，我们可以对诸如调用了哪些 props 以及使用了哪些参数等事项进行断言。我们还可以利用这个时刻来验证表单输入是否已清除。</p>
<p>这让人望而生畏吗？不要害怕。首先将表单添加到渲染函数中。</p>
<p>我可以将这个表单分解成一个单独的组件，但现在我会保持不变。相反，我会将其添加到我桌子旁边的“重构愿望清单”中。</p>
<p>这是 TDD 的方式。当某件事看起来可以重构时，做个记录然后继续。只有在抽象的存在对你有益且不感到多余时才进行重构。</p>
<p>还记得我们通过创建 <code>createProps</code> 工厂来重构测试套件的时候吗？就像那样。我们也可以重构测试。</p>
<p>现在，让我们添加 <code>handleChange</code> 和 <code>handleSubmit</code> 类方法。当我们更改输入或提交表单时，这些方法会被触发。我还将初始化状态。</p>
<p>这样做就可以了。我们的测试通过了，我们有一些类似于真实应用程序的东西。我们的覆盖率看起来如何？</p>
<p><img src="https://cdn-media-1.freecodecamp.org/images/1*Q4coAIT2yaP120pDWGxoAQ.png" alt="1*Q4coAIT2yaP120pDWGxoAQ" width="600" height="400" loading="lazy"></p>
<p>还不错。如果我们忽略 index.js 中的所有设置，我们就有一个完全覆盖的 Web 应用程序，至少在执行的行数方面是这样。</p>
<p>当然，为了验证应用程序是否按照我们的意图工作，我们可能还需要测试其他用例。覆盖率的数字只是你的老板在谈论其他同事时可以炫耀的东西。</p>
<h3 id="">点赞评论</h3>
<p>如何检查我们可以点赞评论呢？这可能是在我们的应用程序中建立某种身份验证概念的好时机。但我们现在还不会跳得太远。首先，让我们更新 props 工厂，为生成的评论添加一个 <code>auth</code> 字段和 ID。</p>
<p>“认证”用户将通过应用程序传递其 <code>auth</code> 属性。与他们是否经过身份验证相关的任何操作都将被记录。</p>
<p>在许多应用程序中，此属性可能包含在向服务器发出请求时发送的某种访问令牌或 cookie 中。</p>
<p>在客户端，此属性的存在使应用程序知道它们可以让用户查看其个人资料或其他受保护的路由。</p>
<p>然而，在这个测试示例中，我们不会过多地处理身份验证。想象这样一个场景：当你进入聊天室时，你提供网名。从那时起，你将负责使用此网名的每个评论，尽管其他人也使用同样的名称登录。</p>
<p>虽然这不是一个很好的解决方案，即使在这个人为的例子中，我们只关心 CommentFeed 组件的行为是否符合预期。我们不关心用户<strong>如何</strong>登录。</p>
<p>换句话说，我们可能有一个完全不同的登录组件来处理特定用户的身份验证，以获得让用户在我们的应用程序中大显身手的全能 <code>auth</code> 属性。</p>
<p>让我们“喜欢”一条评论。添加下一个测试用例，然后更新 props 工厂以包含 <code>likeComment</code> 。</p>
<p>现在，对于实现，我们将首先更新 Comment 组件，使其具有一个点赞按钮以及一个 <code>data-testid</code> 属性，以便我们可以找到它。</p>
<p>我将测试 ID 直接放在按钮上，以便我们可以立即模拟对它的点击，而无需嵌套查询选择器。我还在按钮上附加了一个 onClick 处理程序，以便它传递给它的 onLike 函数。</p>
<p>现在我们只需将此类方法添加到我们的 CommentFeed。</p>
<p>你可能想知道为什么我们不直接将 <code>likeComment</code> 通过 prop 传递给 Comment 组件。为什么我们要将其作为类属性？</p>
<p>在这种情况下，因为它相当简单，我们不必构建这个抽象。将来，我们可能会决定添加其他 onClick 处理程序，例如处理分析事件或启动对该帖子评论的订阅。</p>
<p>在此容器组件的 <code>handleLike</code> 方法中捆绑多个不同的函数调用具有其优势。如果我们愿意，在成功“点赞”后，我们还可以使用此方法更新组件的状态。</p>
<h3 id="">不喜欢评论</h3>
<p>到目前为止，我们已经有了渲染、创建和喜欢评论的工作测试。当然，我们还没有实现实际执行此操作的逻辑——我们没有更新存储或写入数据库。</p>
<p>你可能还注意到，我们正在测试的逻辑很脆弱，不太适用于真实世界的评论反馈。例如，如果我们尝试喜欢我们已经喜欢的评论，会发生什么？它会无限地增加喜欢的计数，还是会取消喜欢？我可以喜欢我自己的评论吗？</p>
<p>我将把组件的功能扩展留给你的想象，但一个好的开始是编写一个新的测试用例。这里有一个基于我们想要实现不喜欢已经喜欢的评论的假设。</p>
<p>请注意，我们正在构建的评论反馈允许我喜欢我自己的评论。谁会这样做？</p>
<p>我已经更新了 Comment 组件，添加了一些逻辑来确定当前用户是否喜欢该评论。</p>
<p>好吧，我有点作弊：在我们之前将 <code>author</code> 传递给 <code>onLike</code> 函数的地方，我改为 <code>currentUser</code> ，这是传递给 Comment 组件的 <code>auth</code> 属性。</p>
<p>毕竟，当其他人喜欢他们的评论时，评论的作者出现在那里是没有意义的。</p>
<p>我之所以意识到这一点，是因为我一直在努力编写测试。如果我只是偶尔编写代码，这一点可能会被我忽略，直到我的一位同事斥责我的无知。</p>
<p>但是这里没有无知，只有测试和随之而来的代码。确保更新 CommentFeed，以便它期望传递 <code>auth</code> 属性。对于 <code>onClick</code> 处理程序，我们可以省略传递 <code>auth</code> 属性，因为我们可以从父级的 <code>handleLike</code> 和 <code>handleDislike</code> 方法中获取 <code>auth</code> 属性。</p>
<h3 id="">总结</h3>
<p>希望你的测试套件看起来像一棵未点亮的圣诞树。</p>
<p>我们可以采取很多不同的方法，这可能会让人有点不知所措。每当你想到某个想法时，只需将其写下来，无论是在纸上还是在新的测试块中。</p>
<p>例如，假设你希望在一个单独的类方法中实现 <code>handleLike</code> 和 <code>handleDislike</code>，但你现在有其他优先事项。你可以通过在测试用例中编写文档来实现这一点：</p>
<p>这并不意味着你需要编写一个全新的测试。你也可以更新前两个案例。但关键是，你可以将测试运行器用作应用程序更加紧迫的“待办事项“列表。</p>
<h4 id="">有用的链接</h4>
<p>有一些很棒的内容涉及到大规模的测试。以下是一些特别启发了本文以及我自己实践的内容。</p>
<ul>
<li><a href="https://www.freecodecamp.org/news/how-to-build-sturdy-react-apps-with-tdd-and-the-react-testing-library-47ad3c5c8e47/undefined">Kent C. Dodds</a> 编写的<a href="https://blog.kentcdodds.com/introducing-the-react-testing-library-e3a274307e65">介绍 React 测试库</a>。了解这个测试库背后的哲学是个好主意。</li>
<li>Kostis Kapelonis 写的<a href="http://blog.codepipes.com/testing/software-testing-antipatterns.html">软件测试反模式</a>，一篇非常深入的文章，讨论了单元和集成测试。还有如何避免错误的方法。</li>
<li>Kent Beck 写的<a href="https://www.amazon.com/Test-Driven-Development-Kent-Beck/dp/0321146530">测试驱动开发的示例</a>。这是一本讨论 TDD 模式的实体书。它不太长，写作风格通俗易懂，便于理解。</li>
</ul>
<p>我希望这些能帮你处理测试问题。</p>
<p>想了解更多文章或机智的评论吗？如果你喜欢这篇文章，请给我一些掌声，并在 <a href="http://medium%5D%28https//medium.com/@iwilsonq">Medium</a>、<a href="https://github.com/iwilsonq">Github</a> 和 <a href="https://twitter.com/iwilsonq">Twitter</a> 上关注我！</p>
<!--kg-card-end: markdown--> ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ 如何用 Jest 和 SuperTest 来测试你的 Express.js 和 Mongoose 应用程序 ]]>
                </title>
                <description>
                    <![CDATA[ 测试是软件开发的重要组成部分，越早进行越好。 在本文中，我将向你展示如何使用 Jest  和 Supertest  为你的 NodeJs/ExpressJS 和 MongoDB/Mongoose 应用程序编写测试。 开始 首先让我们做一个演示 Express.js 应用程序。 假设我们正在为电子商务应用程序构建一个后端 REST API。 这个应用程序：  * 获取所有产品  * 通过 id 获取产品  * 将产品添加到数据库  * 从数据库中删除产品  * 更新产品信息 Express.js 应用设置 步骤一：Project 设置 首先，创建一个文件夹，用npm启动一个空白应用程序。 npm init 填写它所要求的所有细节。 然后，通过下面的命令，安装 express, mongoose, axios  ]]>
                </description>
                <link>https://www.freecodecamp.org/chinese/news/how-to-test-in-express-and-mongoose-apps/</link>
                <guid isPermaLink="false">634cde0231b59b077bbb1eb4</guid>
                
                    <category>
                        <![CDATA[ 测试 ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ luojiyin ]]>
                </dc:creator>
                <pubDate>Fri, 14 Oct 2022 11:16:00 +0000</pubDate>
                <media:content url="https://chinese.freecodecamp.org/news/content/images/2022/10/how-to-write-tests.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p data-test-label="translation-intro">
        <strong>原文：</strong> <a href="https://www.freecodecamp.org/news/how-to-test-in-express-and-mongoose-apps/" target="_blank" rel="noopener noreferrer" data-test-label="original-article-link">How to Test Your Express.js and Mongoose Apps with Jest and SuperTest</a>
      </p><!--kg-card-begin: markdown--><p>测试是软件开发的重要组成部分，越早进行越好。</p>
<p>在本文中，我将向你展示如何使用 <strong>Jest</strong> 和 <strong>Supertest</strong> 为你的 NodeJs/ExpressJS 和 MongoDB/Mongoose 应用程序编写测试。</p>
<h2 id="">开始</h2>
<p>首先让我们做一个演示 Express.js 应用程序。</p>
<p>假设我们正在为电子商务应用程序构建一个后端 REST API。</p>
<p>这个应用程序：</p>
<ul>
<li>获取所有产品</li>
<li>通过 id 获取产品</li>
<li>将产品添加到数据库</li>
<li>从数据库中删除产品</li>
<li>更新产品信息</li>
</ul>
<h2 id="expressjs">Express.js 应用设置</h2>
<h3 id="project">步骤一：Project 设置</h3>
<p>首先，创建一个文件夹，用<code>npm</code>启动一个空白应用程序。</p>
<pre><code class="language-bash">npm init
</code></pre>
<p>填写它所要求的所有细节。</p>
<p>然后，通过下面的命令，安装 <code>express</code>, <code>mongoose</code>, <code>axios</code> 和 <code>dotenv</code>:</p>
<pre><code class="language-bash">npm i express mongoose axios dotenv
</code></pre>
<p>下面是我在 GitHub 上的<a href="https://github.com/itsrakeshhq/jest-tests-demo/blob/a1725cb3379f78a03cf8d3d4cfa22127469e8b50/package.json">package.json</a>的一个链接。</p>
<h3 id="">步骤二：创建模板</h3>
<p>让我们创建所有的文件夹和文件，然后用一些模板代码填充它们。</p>
<p>你的文件夹层次结构应该是这样的：</p>
<pre><code class="language-bash">.
├── controllers
│   └── product.controller.js
├── models
│   └── product.model.js
├── routes
│   └── product.route.js
├── package-lock.json
├── package.json
├── .env
├── app.js
└── server.js
</code></pre>
<p>通过复制和粘贴来使用这些文件的代码。你要尽可能地分析代码和流程。</p>
<ul>
<li><code>[product.controller.js](https://github.com/itsrakeshhq/jest-tests-demo/blob/main/controllers/product.controller.js)</code></li>
<li><code>[product.model.js](https://github.com/itsrakeshhq/jest-tests-demo/blob/main/models/product.model.js)</code></li>
<li><code>[product.route.js](https://github.com/itsrakeshhq/jest-tests-demo/blob/main/routes/product.route.js)</code></li>
<li><code>[app.js](https://github.com/itsrakeshhq/jest-tests-demo/blob/main/app.js)</code></li>
<li><code>[server.js](https://github.com/itsrakeshhq/jest-tests-demo/blob/main/server.js)</code></li>
</ul>
<h3 id="">步骤三：数据库设置</h3>
<p>我建议为一个项目使用两个数据库——一个用于测试，另一个用于开发。但对于学习来说，只用一个数据库就足够了。</p>
<p>首先，创建一个<a href="https://mongodb.com">MongoDB</a>账户或登录。</p>
<p>然后创建一个新的项目。给它起个名字，然后按 <strong>Next</strong> 按钮。</p>
<figure class="kg-card kg-card-image kg-card-hascaption">
    <img src="https://www.freecodecamp.org/news/content/images/2022/09/Screenshot-2022-09-26-205148.png" alt="为项目命名" class="kg-image" width="600" height="400" loading="lazy">
    <figcaption>为项目命名</figcaption>
</figure>
<p>然后点击 <strong>Create Project</strong>。</p>
<p>我们必须在下面的窗口中通过选择一个云供应商、一个位置和规格来创建一个数据库。因此，按 <strong>Build a Database</strong> 就可以开始了。</p>
<figure class="kg-card kg-card-image kg-card-hascaption">
    <img src="https://www.freecodecamp.org/news/content/images/2022/09/Screenshot-2022-09-26-205911.png" alt="创建一个数据库" class="kg-image" width="600" height="400" loading="lazy">
    <figcaption>创建一个数据库</figcaption>
</figure>
<p>选择 <strong>Shared</strong>，因为它足以满足学习目的。然后点击 <strong>Create</strong>。</p>
<figure class="kg-card kg-card-image kg-card-hascaption">
    <img src="https://www.freecodecamp.org/news/content/images/2022/09/Screenshot-2022-09-26-211701.png" alt="选择部署选项" class="kg-image" width="600" height="400" loading="lazy">
    <figcaption>选择部署选项</figcaption>
</figure>
<p>接下来，选择 <code>aws</code> 作为你的云供应商，并选择离你最近的地区。在你选择之后，点击 <strong>Create Cluster</strong>。</p>
<p>该集群的形成将需要一些时间。在此同时，创建一个用户来访问你的数据库。</p>
<figure class="kg-card kg-card-image kg-card-hascaption">
    <img src="https://www.freecodecamp.org/news/content/images/2022/09/Screenshot-2022-09-26-212537.png" alt="创建 Superuser 用户" class="kg-image" width="600" height="400" loading="lazy">
    <figcaption>创建 Superuser 用户</figcaption>
</figure>
<p>选择 “My Local Environment”，因为我们正在开发我们的应用程序。然后你可以添加一个 IP 地址。最后，点击<strong>Close</strong>。</p>
<figure class="kg-card kg-card-image kg-card-hascaption">
    <img src="https://www.freecodecamp.org/news/content/images/2022/09/Screenshot-2022-09-26-213347.png" alt="添加 IP 地址" class="kg-image" width="600" height="400" loading="lazy">
    <figcaption>添加 IP 地址</figcaption>
</figure>
<p>数据库建立后，你会收到一个 URI 字符串，我们将用它来连接数据库。该字符串显示如下：</p>
<pre><code class="language-bash">mongodb+srv://&lt;YOUR_USERNAME&gt;:&lt;YOUR_PASSWORD&gt;@&lt;YOUR_CLUSTER_URL&gt;/&lt;DATABASE_NAME&gt;?retryWrites=true&amp;w=majority
</code></pre>
<p>把这个字符串放在<code>.env</code>文件中。</p>
<pre><code class="language-bash">MONGODB_URI=your database string
</code></pre>
<p>现在我们准备开始测试我们的应用程序。</p>
<h2 id="jestsupertest">如何用 Jest 和 SuperTest 编写测试</h2>
<h3 id="npm">步骤一：安装 npm 包</h3>
<p>你需要三个 npm 包来开始编写测试：<code>jest</code>、<code>supertest</code> 和<code>cross-env</code>。你可以像这样安装它们。</p>
<pre><code class="language-bash">npm i jest supertest cross-env
</code></pre>
<ul>
<li><code>jest</code>：Jest 是一个测试 JavaScript 代码的框架。单元测试是它的主要用途。</li>
<li><code>supertest</code>：使用 Supertest，我们可以测试 HTTP 服务器上的端点和路由。</li>
<li><code>cross-env</code>：你可以使用 cross-env 在命令中内联设置环境变量。</li>
</ul>
<h3 id="step2">Step 2: 添加测试命令</h3>
<p>打开你的<code>package.json</code>文件，将测试命令添加到脚本中。</p>
<pre><code class="language-json">"scripts": {
    "test": "cross-env NODE_ENV=test jest --testTimeout=5000",
    "start": "node server.js",
    "dev": "nodemon server.js"
},
</code></pre>
<p>在这个案例中，我们使用<code>cross-env</code>来设置环境变量，<code>jest</code>来执行测试套件，<code>testTimeout</code>被设置为<code>5000</code>，因为某些请求可能需要一段时间才能完成。</p>
<h3 id="">步骤三：开始编写测试代码</h3>
<p>首先，在应用程序的根目录下创建一个名为<code>tests</code>的文件夹，然后在那里创建一个名为<code>product.test.js</code>的文件。当你执行 <code>npm run test</code> 时，Jest 会在项目的根目录下搜索 <code>tests</code> 文件夹。因此，你必须将你的测试文件放在<code>tests</code>文件夹中。</p>
<p>接下来，在测试文件中导入<code>supertest</code>和<code>mongoose</code>包。</p>
<pre><code class="language-javascript">const mongoose = require("mongoose");
const request = require("supertest");
</code></pre>
<p>导入<code>dotenv</code>以加载环境变量，并导入<code>app.js</code>，因为那是我们的应用程序启动的地方。</p>
<pre><code class="language-javascript">const mongoose = require("mongoose");
const request = require("supertest");
const app = require("../app");

require("dotenv").config();
</code></pre>
<p>你需要在每次测试前连接数据库和测试后断开数据库（因为测试完成后我们不需要数据库）。</p>
<pre><code class="language-javascript">/* 在每次测试前连接到数据库。*/
beforeEach(async () =&gt; {
  await mongoose.connect(process.env.MONGODB_URI);
});

/* 每次测试后关闭数据库连接。*/
afterEach(async () =&gt; {
  await mongoose.connection.close();
});
</code></pre>
<p>现在你可以写你的第一个单元测试。</p>
<pre><code class="language-javascript">describe("GET /api/products", () =&gt; {
  it("should return all products", async () =&gt; {
    const res = await request(app).get("/api/products");
    expect(res.statusCode).toBe(200);
    expect(res.body.length).toBeGreaterThan(0);
  });
});
</code></pre>
<p>在上面的代码中，</p>
<ul>
<li>我们使用<code>describe</code>来描述单元测试。尽管它不是必需的，但它对于在测试结果中识别测试是很有用的。</li>
<li>在<code>it</code>中，我们写了实际的测试代码。在第一个参数中告诉测试执行的内容，然后在第二个参数中，写一个包含测试代码的回调函数。</li>
<li>在回调函数中，首先向端点（endpoint）发送请求，然后比较预期和实际的响应。如果两个答案都吻合，测试就通过，否则就失败。就这么简单✨。</li>
</ul>
<p>你可以以同样的方式为所有的端点编写测试。</p>
<pre><code class="language-javascript">describe("GET /api/products/:id", () =&gt; {
  it("should return a product", async () =&gt; {
    const res = await request(app).get(
      "/api/products/6331abc9e9ececcc2d449e44"
    );
    expect(res.statusCode).toBe(200);
    expect(res.body.name).toBe("Product 1");
  });
});

describe("POST /api/products", () =&gt; {
  it("should create a product", async () =&gt; {
    const res = await request(app).post("/api/products").send({
      name: "Product 2",
      price: 1009,
      description: "Description 2",
    });
    expect(res.statusCode).toBe(201);
    expect(res.body.name).toBe("Product 2");
  });
});

describe("PUT /api/products/:id", () =&gt; {
  it("should update a product", async () =&gt; {
    const res = await request(app)
      .patch("/api/products/6331abc9e9ececcc2d449e44")
      .send({
        name: "Product 4",
        price: 104,
        description: "Description 4",
      });
    expect(res.statusCode).toBe(200);
    expect(res.body.price).toBe(104);
  });
});

describe("DELETE /api/products/:id", () =&gt; {
  it("should delete a product", async () =&gt; {
    const res = await request(app).delete(
      "/api/products/6331abc9e9ececcc2d449e44"
    );
    expect(res.statusCode).toBe(200);
  });
});
</code></pre>
<p>然后运行<code>npm run test</code>来运行测试套件（套件-测试文件）。</p>
<figure class="kg-card kg-card-image kg-card-hascaption">
    <img src="https://www.freecodecamp.org/news/content/images/2022/09/image-428.png" alt="测试结果" class="kg-image" width="600" height="400" loading="lazy">
    <figcaption>测试结果</figcaption>
</figure>
<p>就是这些了！你现在知道如何用 Jest 和 SuperTest 来测试你的 Express/Mongoose 应用程序了。</p>
<p>现在去为你的应用程序创建新的测试吧！:)</p>
<p>如果你有任何问题，请随时在 <a href="https://twitter.com/rakesh_at_tweet">Twitter</a> 上给我留言。</p>
<!--kg-card-end: markdown--> ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ 超详细的前端工程化入门教程 ]]>
                </title>
                <description>
                    <![CDATA[ 本文将分成以下 7 个小节：  1. 技术选型  2. 统一规范  3. 测试  4. 部署  5. 监控  6. 性能优化  7. 重构 部分小节提供了非常详细的实战教程，让大家动手实践。 另外我还写了一个前端工程化 demo，放在 GitHub [https://github.com/woai3c/front-end-engineering-demo] 上。这个 demo 包含了 js、css、git 验证，其中 js、css 验证需要安装 VSCode，具体教程在下文中会有提及。 技术选型 对于前端来说，技术选型挺简单的。就是做选择题，三大框架中选一个。个人认为可以依据以下两个特点来选：  1. 选你或团队最熟的，保证在遇到棘手的问题时有人能填坑。  2. 选市场占有率高的。换句话说，就是选好招人的。 第二点对于小公司来说，特别重要。本来小公司就不好招人，要是还选一个市场占有率不高的框架（例如 Angular），简历你都看不到几个... UI 组件库更简单，github 上哪个 ]]>
                </description>
                <link>https://www.freecodecamp.org/chinese/news/front-end-engineering-tutorial/</link>
                <guid isPermaLink="false">5fa915165f583f0565090fd9</guid>
                
                    <category>
                        <![CDATA[ 前端 ]]>
                    </category>
                
                    <category>
                        <![CDATA[ 优化 ]]>
                    </category>
                
                    <category>
                        <![CDATA[ 测试 ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ woai3c ]]>
                </dc:creator>
                <pubDate>Wed, 28 Jul 2021 10:32:00 +0000</pubDate>
                <media:content url="https://chinese.freecodecamp.org/news/content/images/2020/11/tim-mossholder-WE_Kv_ZB1l0-unsplash.jpg" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>本文将分成以下 7 个小节：</p>
<ol>
<li>技术选型</li>
<li>统一规范</li>
<li>测试</li>
<li>部署</li>
<li>监控</li>
<li>性能优化</li>
<li>重构</li>
</ol>
<p>部分小节提供了非常详细的实战教程，让大家动手实践。</p>
<p>另外我还写了一个前端工程化 demo，放在 <a href="https://github.com/woai3c/front-end-engineering-demo">GitHub</a> 上。这个 demo 包含了 js、css、git 验证，其中 js、css 验证需要安装 VSCode，具体教程在下文中会有提及。</p>
<h2 id="">技术选型</h2>
<p>对于前端来说，技术选型挺简单的。就是做选择题，三大框架中选一个。个人认为可以依据以下两个特点来选：</p>
<ol>
<li>选你或团队最熟的，保证在遇到棘手的问题时有人能填坑。</li>
<li>选市场占有率高的。换句话说，就是选好招人的。</li>
</ol>
<p>第二点对于小公司来说，特别重要。本来小公司就不好招人，要是还选一个市场占有率不高的框架（例如 Angular），简历你都看不到几个...</p>
<p><img src="https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/62b7a34ed09e4f5ba2aec46ed7c54de8~tplv-k3u1fbpfcp-watermark.image" alt="62b7a34ed09e4f5ba2aec46ed7c54de8~tplv-k3u1fbpfcp-watermark" width="600" height="400" loading="lazy"></p>
<p><img src="https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/c78b05aa770e4b6f8f553879eaa0dc02~tplv-k3u1fbpfcp-watermark.image" alt="c78b05aa770e4b6f8f553879eaa0dc02~tplv-k3u1fbpfcp-watermark" width="600" height="400" loading="lazy"></p>
<p>UI 组件库更简单，github 上哪个 star 多就用哪个。star 多，说明用的人就多，很多坑别人都替你踩过了，省事。</p>
<h2 id="">统一规范</h2>
<h3 id="">代码规范</h3>
<p>先来看看统一代码规范的好处：</p>
<ul>
<li>规范的代码可以促进团队合作</li>
<li>规范的代码可以降低维护成本</li>
<li>规范的代码有助于 code review（代码审查）</li>
<li>养成代码规范的习惯，有助于程序员自身的成长</li>
</ul>
<p>当团队的成员都严格按照代码规范来写代码时，可以保证每个人的代码看起来都像是一个人写的，看别人的代码就像是在看自己的代码。更重要的是我们能够认识到规范的重要性，并坚持规范的开发习惯。</p>
<h4 id="">如何制订代码规范</h4>
<p>建议找一份好的代码规范，在此基础上结合团队的需求作个性化修改。</p>
<p>下面列举一些 star 较多的 js 代码规范：</p>
<ul>
<li><a href="https://github.com/airbnb/javascript">airbnb (101k star 英文版)</a>，<a href="https://github.com/lin-123/javascript">airbnb-中文版</a></li>
<li><a href="https://github.com/standard/standard/blob/master/docs/README-zhcn.md">standard (24.5k star) 中文版</a></li>
<li><a href="https://github.com/ecomfe/spec">百度前端编码规范 3.9k</a></li>
</ul>
<p>css 代码规范也有不少，例如：</p>
<ul>
<li><a href="https://github.com/fex-team/styleguide/blob/master/css.md">styleguide 2.3k</a></li>
<li><a href="https://github.com/ecomfe/spec/blob/master/css-style-guide.md">spec 3.9k</a></li>
</ul>
<h4 id="">如何检查代码规范</h4>
<p>使用 eslint 可以检查代码符不符合团队制订的规范，下面来看一下如何配置 eslint 来检查代码。</p>
<ol>
<li>下载依赖</li>
</ol>
<pre><code>// eslint-config-airbnb-base 使用 airbnb 代码规范
npm i -D babel-eslint eslint eslint-config-airbnb-base eslint-plugin-import
</code></pre>
<ol start="2">
<li>在 <code>package.json</code> 的 <code>scripts</code> 加上这行代码 <code>"lint": "eslint --ext .js test/ src/ bin/"</code>。然后执行 <code>npm run lint</code> 即可开始验证代码。</li>
</ol>
<p>不过这样检查代码效率太低，每次都得手动检查。并且报错了还得手动修改代码。</p>
<p>为了改善以上缺点，我们可以使用 VSCode。使用它并加上适当的配置可以在每次保存代码的时候，自动验证代码并进行格式化，省去了动手的麻烦。</p>
<p>css 检查代码规范则使用 <code>stylelint</code> 插件。</p>
<p>由于篇幅有限，具体如何配置请看我的另一篇文章：<a href="https://juejin.im/post/6892000216020189198/">ESlint + stylelint + VSCode自动格式化代码（2020）</a>。</p>
<p><img src="//p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/f1bd13cb84a34d78aaec9f1e98c17790~tplv-k3u1fbpfcp-zoom-1.image" alt="f1bd13cb84a34d78aaec9f1e98c17790~tplv-k3u1fbpfcp-zoom-1" width="600" height="400" loading="lazy"></p>
<p><img src="//p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/9b4d8e17b0f2488c8379541544147ef3~tplv-k3u1fbpfcp-zoom-1.image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></p>
<h3 id="git">git 规范</h3>
<p>git 规范包括两点：分支管理规范、git commit 规范。</p>
<h4 id="">分支管理规范</h4>
<p>一般项目分主分支（master）和其他分支。</p>
<p>当有团队成员要开发新功能或改 BUG 时，就从 master 分支开一个新的分支。例如项目要从客户端渲染改成服务端渲染，就开一个分支叫 ssr，开发完了再合并回 master 分支。</p>
<p>如果改一个 BUG，也可以从 master 分支开一个新分支，并用 BUG 号命名（不过我们小团队嫌麻烦，没这样做，除非有特别大的 BUG）。</p>
<h4 id="gitcommit">git commit 规范</h4>
<pre><code class="language-md">&lt;type&gt;(&lt;scope&gt;): &lt;subject&gt;
&lt;BLANK LINE&gt;
&lt;body&gt;
&lt;BLANK LINE&gt;
&lt;footer&gt;
</code></pre>
<p>大致分为三个部分(使用空行分割):</p>
<ol>
<li>标题行: 必填, 描述主要修改类型和内容</li>
<li>主题内容: 描述为什么修改, 做了什么样的修改, 以及开发的思路等等</li>
<li>页脚注释: 可以写注释，BUG 号链接</li>
</ol>
<h4 id="typecommit">type: commit 的类型</h4>
<ul>
<li>feat: 新功能、新特性</li>
<li>fix: 修改 bug</li>
<li>perf: 更改代码，以提高性能</li>
<li>refactor: 代码重构（重构，在不影响代码内部行为、功能下的代码修改）</li>
<li>docs: 文档修改</li>
<li>style: 代码格式修改, 注意不是 css 修改（例如分号修改）</li>
<li>test: 测试用例新增、修改</li>
<li>build: 影响项目构建或依赖项修改</li>
<li>revert: 恢复上一次提交</li>
<li>ci: 持续集成相关文件修改</li>
<li>chore: 其他修改（不在上述类型中的修改）</li>
<li>release: 发布新版本</li>
<li>workflow: 工作流相关文件修改</li>
</ul>
<ol>
<li>scope: commit 影响的范围, 比如: route, component, utils, build...</li>
<li>subject: commit 的概述</li>
<li>body: commit 具体修改内容, 可以分为多行.</li>
<li>footer: 一些备注, 通常是 BREAKING CHANGE 或修复的 bug 的链接.</li>
</ol>
<p>示例</p>
<h5 id="fixbug">fix（修复BUG）</h5>
<p>如果修复的这个BUG只影响当前修改的文件，可不加范围。如果影响的范围比较大，要加上范围描述。</p>
<p>例如这次 BUG 修复影响到全局，可以加个 global。如果影响的是某个目录或某个功能，可以加上该目录的路径，或者对应的功能名称。</p>
<pre><code class="language-js">// 示例1
fix(global):修复checkbox不能复选的问题
// 示例2 下面圆括号里的 common 为通用管理的名称
fix(common): 修复字体过小的BUG，将通用管理下所有页面的默认字体大小修改为 14px
// 示例3
fix: value.length -&gt; values.length
</code></pre>
<h5 id="feat">feat（添加新功能或新页面）</h5>
<pre><code class="language-js">feat: 添加网站主页静态页面

这是一个示例，假设对点检任务静态页面进行了一些描述。
 
这里是备注，可以是放BUG链接或者一些重要性的东西。
</code></pre>
<h5 id="chore">chore（其他修改）</h5>
<p>chore 的中文翻译为日常事务、例行工作，顾名思义，即不在其他 commit 类型中的修改，都可以用 chore 表示。</p>
<pre><code class="language-js">chore: 将表格中的查看详情改为详情
</code></pre>
<p>其他类型的 commit 和上面三个示例差不多，就不说了。</p>
<h4 id="gitcommit">验证 git commit 规范</h4>
<p>验证 git commit 规范，主要通过 git 的 <code>pre-commit</code> 钩子函数来进行。当然，你还需要下载一个辅助工具来帮助你进行验证。</p>
<p>下载辅助工具</p>
<pre><code>npm i -D husky
</code></pre>
<p>在 <code>package.json</code> 加上下面的代码</p>
<pre><code class="language-json">"husky": {
  "hooks": {
    "pre-commit": "npm run lint",
    "commit-msg": "node script/verify-commit.js",
    "pre-push": "npm test"
  }
}
</code></pre>
<p>然后在你项目根目录下新建一个文件夹 <code>script</code>，并在下面新建一个文件 <code>verify-commit.js</code>，输入以下代码：</p>
<pre><code class="language-js">const msgPath = process.env.HUSKY_GIT_PARAMS
const msg = require('fs')
.readFileSync(msgPath, 'utf-8')
.trim()

const commitRE = /^(feat|fix|docs|style|refactor|perf|test|workflow|build|ci|chore|release|workflow)(\(.+\))?: .{1,50}/

if (!commitRE.test(msg)) {
    console.log()
    console.error(`
        不合法的 commit 消息格式。
        请查看 git commit 提交规范：https://github.com/woai3c/Front-end-articles/blob/master/git%20commit%20style.md
    `)

    process.exit(1)
}
</code></pre>
<p>现在来解释下各个钩子的含义：</p>
<ol>
<li><code>"pre-commit": "npm run lint"</code>，在 <code>git commit</code> 前执行 <code>npm run lint</code> 检查代码格式。</li>
<li><code>"commit-msg": "node script/verify-commit.js"</code>，在 <code>git commit</code> 时执行脚本 <code>verify-commit.js</code> 验证 commit 消息。如果不符合脚本中定义的格式，将会报错。</li>
<li><code>"pre-push": "npm test"</code>，在你执行 <code>git push</code> 将代码推送到远程仓库前，执行 <code>npm test</code> 进行测试。如果测试失败，将不会执行这次推送。</li>
</ol>
<h3 id="">项目规范</h3>
<p>主要是项目文件的组织方式和命名方式。</p>
<p>用我们的 Vue 项目举个例子。</p>
<pre><code>├─public
├─src
├─test
</code></pre>
<p>一个项目包含 public（公共资源，不会被 webpack 处理）、src（源码）、test（测试代码），其中 src 目录，又可以细分。</p>
<pre><code>├─api （接口）
├─assets （静态资源）
├─components （公共组件）
├─styles （公共样式）
├─router （路由）
├─store （vuex 全局数据）
├─utils （工具函数）
└─views （页面）
</code></pre>
<p>文件名称如果过长则用 - 隔开。</p>
<h3 id="ui">UI 规范</h3>
<p>UI 规范需要前端、UI、产品沟通，互相商量，最后制定下来，建议使用统一的 UI 组件库。</p>
<p>制定 UI 规范的好处：</p>
<ul>
<li>统一页面 UI 标准，节省 UI 设计时间</li>
<li>提高前端开发效率</li>
</ul>
<h2 id="">测试</h2>
<p>测试是前端工程化建设必不可少的一部分，它的作用就是找出 bug，越早发现 bug，所需要付出的成本就越低。并且，它更重要的作用是在将来，而不是当下。</p>
<p>设想一下半年后，你的项目要加一个新功能。在加完新功能后，你不确定有没有影响到原有的功能，需要测试一下。由于时间过去太久，你对项目的代码已经不了解了。在这种情况下，如果没有写测试，你就得手动一遍一遍的去试。而如果写了测试，你只需要跑一遍测试代码就 OK 了，省时省力。</p>
<p>写测试还可以让你修改代码时没有心理负担，不用一直想着改这里有没有问题？会不会引起 BUG？而写了测试就没有这种担心了。</p>
<p>在前端用得最多的就是单元测试（主要是端到端测试我用得很少，不熟），这里着重讲解一下。</p>
<h3 id="">单元测试</h3>
<p>单元测试就是对一个函数、一个组件、一个类做的测试，它针对的粒度比较小。</p>
<p>它应该怎么写呢？</p>
<ol>
<li>根据正确性写测试，即正确的输入应该有正常的结果。</li>
<li>根据异常写测试，即错误的输入应该是错误的结果。</li>
</ol>
<h4 id="">对一个函数做测试</h4>
<p>例如一个取绝对值的函数 <code>abs()</code>，输入 <code>1,2</code>，结果应该与输入相同；输入 <code>-1,-2</code>，结果应该与输入相反。如果输入非数字，例如 <code>"abc"</code>，应该抛出一个类型错误。</p>
<h4 id="">对一个类做测试</h4>
<p>假设有这样一个类：</p>
<pre><code>class Math {
    abs() {

    }

    sqrt() {

    }

    pow() {

    }
    ...
}
</code></pre>
<p>单元测试，必须把这个类的所有方法都测一遍。</p>
<h4 id="">对一个组件做测试</h4>
<p>组件测试比较难，因为很多组件都涉及了 DOM 操作。</p>
<p>例如一个上传图片组件，它有一个将图片转成 base64 码的方法，那要怎么测试呢？一般测试都是跑在 node 环境下的，而 node 环境没有 DOM 对象。</p>
<p>我们先来回顾一下上传图片的过程：</p>
<ol>
<li>点击 <code>&lt;input type="file" /&gt;</code>，选择图片上传。</li>
<li>触发 <code>input</code> 的 <code>change</code> 事件，获取 <code>file</code> 对象。</li>
<li>用 <code>FileReader</code> 将图片转换成 base64 码。</li>
</ol>
<p>这个过程和下面的代码是一样的：</p>
<pre><code class="language-js">document.querySelector('input').onchange = function fileChangeHandler(e) {
    const file = e.target.files[0]
    const reader = new FileReader()
    reader.onload = (res) =&gt; {
        const fileResult = res.target.result
        console.log(fileResult) // 输出 base64 码
    }

    reader.readAsDataURL(file)
}
</code></pre>
<p>上面的代码只是模拟，真实情况下应该是这样使用</p>
<pre><code class="language-js">document.querySelector('input').onchange = function fileChangeHandler(e) {
    const file = e.target.files[0]
    tobase64(file)
}

function tobase64(file) {
    return new Promise((resolve, reject) =&gt; {
        const reader = new FileReader()
        reader.onload = (res) =&gt; {
            const fileResult = res.target.result
            resolve(fileResult) // 输出 base64 码
        }

        reader.readAsDataURL(file)
    })
}
</code></pre>
<p>可以看到，上面代码出现了 window 的事件对象 <code>event</code>、<code>FileReader</code>。也就是说，只要我们能够提供这两个对象，就可以在任何环境下运行它。所以我们可以在测试环境下加上这两个对象：</p>
<pre><code class="language-js">// 重写 File
window.File = function () {}

// 重写 FileReader
window.FileReader = function () {
    this.readAsDataURL = function () {
        this.onload
            &amp;&amp; this.onload({
                target: {
                    result: fileData,
                },
            })
    }
}
</code></pre>
<p>然后测试可以这样写：</p>
<pre><code class="language-js">// 提前写好文件内容
const fileData = 'data:image/test'

// 提供一个假的 file 对象给 tobase64() 函数
function test() {
    const file = new File()
    const event = { target: { files: [file] } }
    file.type = 'image/png'
    file.name = 'test.png'
    file.size = 1024

    it('file content', (done) =&gt; {
        tobase64(file).then(base64 =&gt; {
            expect(base64).toEqual(fileData) // 'data:image/test'
            done()
        })
    })
}

// 执行测试
test()
</code></pre>
<p>通过这种 hack 的方式，我们就实现了对涉及 DOM 操作的组件的测试。我的 <a href="https://github.com/woai3c/vue-upload-imgs">vue-upload-imgs</a> 库就是通过这种方式写的单元测试，有兴趣可以了解一下。</p>
<h3 id="tdd">TDD 测试驱动开发</h3>
<p>TDD 就是根据需求提前把测试代码写好，然后根据测试代码实现功能。</p>
<p>TDD 的初衷是好的，但如果你的需求经常变（你懂的），那就不是一件好事了。很有可能你天天都在改测试代码，业务代码反而没怎么动。<br>
所以到现在为止，三年多的程序员生涯，我还没尝试过 TDD 开发。</p>
<p>虽然环境如此艰难，但有条件的情况下还是应该试一下 TDD 的。例如在你自己负责一个项目又不忙的时候，可以采用此方法编写测试用例。</p>
<h3 id="">测试框架推荐</h3>
<p>我常用的测试框架是 <a href="https://jestjs.io/docs/zh-Hans/getting-started">jest</a>，好处是有中文文档，API 清晰明了，一看就知道是干什么用的。</p>
<h2 id="">部署</h2>
<p>在没有学会自动部署前，我是这样部署项目的：</p>
<ol>
<li>执行测试 <code>npm run test</code>。</li>
<li>推送代码 <code>git push</code>。</li>
<li>构建项目 <code>npm run build</code>。</li>
<li>将打包好的文件放到静态服务器。</li>
</ol>
<p>一次两次还行，如果天天都这样，就会把很多时间浪费在重复的操作上。所以我们要学会自动部署，彻底解放双手。</p>
<p>自动部署（又叫持续部署 Continuous Deployment，英文缩写 CD）一般有两种触发方式：</p>
<ol>
<li>轮询。</li>
<li>监听 <code>webhook</code> 事件。</li>
</ol>
<h3 id="">轮询</h3>
<p>轮询，就是构建软件每隔一段时间自动执行打包、部署操作。</p>
<p>这种方式不太好，很有可能软件刚部署完我就改代码了。为了看到新的页面效果，不得不等到下一次构建开始。</p>
<p>另外还有一个副作用，假如我一天都没更改代码，构建软件还是会不停的执行打包、部署操作，白白的浪费资源。</p>
<p>所以现在的构建软件基本采用监听 <code>webhook</code> 事件的方式来进行部署。</p>
<h3 id="webhook">监听 <code>webhook</code> 事件</h3>
<p>webhook 钩子函数，就是在你的构建软件上进行设置，监听某一个事件（一般是监听 <code>push</code> 事件），当事件触发时，自动执行定义好的脚本。</p>
<p>例如 <code>Github Actions</code>，就有这个功能。</p>
<p><img src="https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/d042f518cdce4a1b90ab165d256001aa~tplv-k3u1fbpfcp-watermark.image" alt="d042f518cdce4a1b90ab165d256001aa~tplv-k3u1fbpfcp-watermark" width="600" height="400" loading="lazy"></p>
<p>对于新人来说，仅看我这一段讲解是不可能学会自动部署的。为此我特地写了一篇自动化部署教程，不需要你提前学习自动化部署的知识，只要照着指引做，就能实现前端项目自动化部署。</p>
<p><a href="https://juejin.im/post/6887751398499287054">前端项目自动化部署——超详细教程（Jenkins、Github Actions）</a>，教程已经奉上，各位大佬看完后要是觉得有用，不要忘了点赞，感激不尽。</p>
<h2 id="">监控</h2>
<p>监控，又分性能监控和错误监控，它的作用是预警和追踪定位问题。</p>
<h3 id="">性能监控</h3>
<p>性能监控一般利用 <code>window.performance</code> 来进行数据采集。</p>
<blockquote>
<p>Performance 接口可以获取到当前页面中与性能相关的信息，它是 High Resolution Time API 的一部分，同时也融合了 Performance Timeline API、Navigation Timing API、 User Timing API 和 Resource Timing API。</p>
</blockquote>
<p>这个 API 的属性 <code>timing</code>，包含了页面加载各个阶段的起始及结束时间。</p>
<p><img src="//p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/43b6b58259a14129914cea4f20071ba7~tplv-k3u1fbpfcp-zoom-1.image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"><br>
<img src="//p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/df66c22c29c64be7bc4d16e7aedffae3~tplv-k3u1fbpfcp-zoom-1.image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></p>
<p>为了方便大家理解 <code>timing</code> 各个属性的意义，我在知乎找到一位网友对于 <code>timing</code> 写的简介（忘了姓名，后来找不到了，见谅），在此转载一下。</p>
<pre><code class="language-js">timing: {
        // 同一个浏览器上一个页面卸载(unload)结束时的时间戳。如果没有上一个页面，这个值会和fetchStart相同。
	navigationStart: 1543806782096,

	// 上一个页面unload事件抛出时的时间戳。如果没有上一个页面，这个值会返回0。
	unloadEventStart: 1543806782523,

	// 和 unloadEventStart 相对应，unload事件处理完成时的时间戳。如果没有上一个页面,这个值会返回0。
	unloadEventEnd: 1543806782523,

	// 第一个HTTP重定向开始时的时间戳。如果没有重定向，或者重定向中的一个不同源，这个值会返回0。
	redirectStart: 0,

	// 最后一个HTTP重定向完成时（也就是说是HTTP响应的最后一个比特直接被收到的时间）的时间戳。
	// 如果没有重定向，或者重定向中的一个不同源，这个值会返回0. 
	redirectEnd: 0,

	// 浏览器准备好使用HTTP请求来获取(fetch)文档的时间戳。这个时间点会在检查任何应用缓存之前。
	fetchStart: 1543806782096,

	// DNS 域名查询开始的UNIX时间戳。
        //如果使用了持续连接(persistent connection)，或者这个信息存储到了缓存或者本地资源上，这个值将和fetchStart一致。
	domainLookupStart: 1543806782096,

	// DNS 域名查询完成的时间.
	//如果使用了本地缓存（即无 DNS 查询）或持久连接，则与 fetchStart 值相等
	domainLookupEnd: 1543806782096,

	// HTTP（TCP） 域名查询结束的时间戳。
        //如果使用了持续连接(persistent connection)，或者这个信息存储到了缓存或者本地资源上，这个值将和 fetchStart一致。
	connectStart: 1543806782099,

	// HTTP（TCP） 返回浏览器与服务器之间的连接建立时的时间戳。
        // 如果建立的是持久连接，则返回值等同于fetchStart属性的值。连接建立指的是所有握手和认证过程全部结束。
	connectEnd: 1543806782227,

	// HTTPS 返回浏览器与服务器开始安全链接的握手时的时间戳。如果当前网页不要求安全连接，则返回0。
	secureConnectionStart: 1543806782162,

	// 返回浏览器向服务器发出HTTP请求时（或开始读取本地缓存时）的时间戳。
	requestStart: 1543806782241,

	// 返回浏览器从服务器收到（或从本地缓存读取）第一个字节时的时间戳。
        //如果传输层在开始请求之后失败并且连接被重开，该属性将会被数制成新的请求的相对应的发起时间。
	responseStart: 1543806782516,

	// 返回浏览器从服务器收到（或从本地缓存读取，或从本地资源读取）最后一个字节时
        //（如果在此之前HTTP连接已经关闭，则返回关闭时）的时间戳。
	responseEnd: 1543806782537,

	// 当前网页DOM结构开始解析时（即Document.readyState属性变为“loading”、相应的 readystatechange事件触发时）的时间戳。
	domLoading: 1543806782573,

	// 当前网页DOM结构结束解析、开始加载内嵌资源时（即Document.readyState属性变为“interactive”、相应的readystatechange事件触发时）的时间戳。
	domInteractive: 1543806783203,

	// 当解析器发送DOMContentLoaded 事件，即所有需要被执行的脚本已经被解析时的时间戳。
	domContentLoadedEventStart: 1543806783203,

	// 当所有需要立即执行的脚本已经被执行（不论执行顺序）时的时间戳。
	domContentLoadedEventEnd: 1543806783216,

	// 当前文档解析完成，即Document.readyState 变为 'complete'且相对应的readystatechange 被触发时的时间戳
	domComplete: 1543806783796,

	// load事件被发送时的时间戳。如果这个事件还未被发送，它的值将会是0。
	loadEventStart: 1543806783796,

	// 当load事件结束，即加载事件完成时的时间戳。如果这个事件还未被发送，或者尚未完成，它的值将会是0.
	loadEventEnd: 1543806783802
}
</code></pre>
<p>通过以上数据，我们可以得到几个有用的时间</p>
<pre><code class="language-js">// 重定向耗时
redirect: timing.redirectEnd - timing.redirectStart,
// DOM 渲染耗时
dom: timing.domComplete - timing.domLoading,
// 页面加载耗时
load: timing.loadEventEnd - timing.navigationStart,
// 页面卸载耗时
unload: timing.unloadEventEnd - timing.unloadEventStart,
// 请求耗时
request: timing.responseEnd - timing.requestStart,
// 获取性能信息时当前时间
time: new Date().getTime(),
</code></pre>
<p>还有一个比较重要的时间就是<strong>白屏时间</strong>，它指从输入网址，到页面开始显示内容的时间。</p>
<p>将以下脚本放在 <code>&lt;/head&gt;</code> 前面就能获取白屏时间。</p>
<pre><code class="language-html">&lt;script&gt;
    whiteScreen = new Date() - performance.timing.navigationStart
&lt;/script&gt;
</code></pre>
<p>通过这几个时间，就可以得知页面首屏加载性能如何了。</p>
<p>另外，通过 <code>window.performance.getEntriesByType('resource')</code> 这个方法，我们还可以获取相关资源（js、css、img...）的加载时间，它会返回页面当前所加载的所有资源。</p>
<p><img src="//p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/522dff06f2b445c193ffa7f4b365a9b4~tplv-k3u1fbpfcp-zoom-1.image" alt="在这里插入图片描述" width="600" height="400" loading="lazy"></p>
<p>它一般包括以下几个类型</p>
<ul>
<li>sciprt</li>
<li>link</li>
<li>img</li>
<li>css</li>
<li>fetch</li>
<li>other</li>
<li>xmlhttprequest</li>
</ul>
<p>我们只需用到以下几个信息</p>
<pre><code class="language-js">// 资源的名称
name: item.name,
// 资源加载耗时
duration: item.duration.toFixed(2),
// 资源大小
size: item.transferSize,
// 资源所用协议
protocol: item.nextHopProtocol,
</code></pre>
<p>现在，写几行代码来收集这些数据。</p>
<pre><code class="language-js">// 收集性能信息
const getPerformance = () =&gt; {
    if (!window.performance) return
    const timing = window.performance.timing
    const performance = {
        // 重定向耗时
        redirect: timing.redirectEnd - timing.redirectStart,
        // 白屏时间
        whiteScreen: whiteScreen,
        // DOM 渲染耗时
        dom: timing.domComplete - timing.domLoading,
        // 页面加载耗时
        load: timing.loadEventEnd - timing.navigationStart,
        // 页面卸载耗时
        unload: timing.unloadEventEnd - timing.unloadEventStart,
        // 请求耗时
        request: timing.responseEnd - timing.requestStart,
        // 获取性能信息时当前时间
        time: new Date().getTime(),
    }

    return performance
}

// 获取资源信息
const getResources = () =&gt; {
    if (!window.performance) return
    const data = window.performance.getEntriesByType('resource')
    const resource = {
        xmlhttprequest: [],
        css: [],
        other: [],
        script: [],
        img: [],
        link: [],
        fetch: [],
        // 获取资源信息时当前时间
        time: new Date().getTime(),
    }

    data.forEach(item =&gt; {
        const arry = resource[item.initiatorType]
        arry &amp;&amp; arry.push({
            // 资源的名称
            name: item.name,
            // 资源加载耗时
            duration: item.duration.toFixed(2),
            // 资源大小
            size: item.transferSize,
            // 资源所用协议
            protocol: item.nextHopProtocol,
        })
    })

    return resource
}
</code></pre>
<h4 id="">小结</h4>
<p>通过对性能及资源信息的解读，我们可以判断出页面加载慢有以下几个原因：</p>
<ol>
<li>资源过多</li>
<li>网速过慢</li>
<li>DOM元素过多</li>
</ol>
<p>除了用户网速过慢，我们没办法之外，其他两个原因都是有办法解决的，性能优化将在下一节《性能优化》中会讲到。</p>
<h3 id="">错误监控</h3>
<p>现在能捕捉的错误有三种。</p>
<ol>
<li>资源加载错误，通过 <code>addEventListener('error', callback, true)</code> 在捕获阶段捕捉资源加载失败错误。</li>
<li>js 执行错误，通过 <code>window.onerror</code> 捕捉 js 错误。</li>
<li>promise 错误，通过 <code>addEventListener('unhandledrejection', callback)</code>捕捉 promise 错误，但是没有发生错误的行数，列数等信息，只能手动抛出相关错误信息。</li>
</ol>
<p>我们可以建一个错误数组变量 <code>errors</code> 在错误发生时，将错误的相关信息添加到数组，然后在某个阶段统一上报，具体如何操作请看代码</p>
<pre><code class="language-js">// 捕获资源加载失败错误 js css img...
addEventListener('error', e =&gt; {
    const target = e.target
    if (target != window) {
        monitor.errors.push({
            type: target.localName,
            url: target.src || target.href,
            msg: (target.src || target.href) + ' is load error',
            // 错误发生的时间
            time: new Date().getTime(),
        })
    }
}, true)

// 监听 js 错误
window.onerror = function(msg, url, row, col, error) {
    monitor.errors.push({
        type: 'javascript',
        row: row,
        col: col,
        msg: error &amp;&amp; error.stack? error.stack : msg,
        url: url,
        // 错误发生的时间
        time: new Date().getTime(),
    })
}

// 监听 promise 错误 缺点是获取不到行数数据
addEventListener('unhandledrejection', e =&gt; {
    monitor.errors.push({
        type: 'promise',
        msg: (e.reason &amp;&amp; e.reason.msg) || e.reason || '',
        // 错误发生的时间
        time: new Date().getTime(),
    })
})
</code></pre>
<h4 id="">小结</h4>
<p>通过错误收集，可以了解到网站错误发生的类型及数量，从而可以做相应的调整，以减少错误发生。<br>
完整代码和 DEMO 请看我另一篇文章<a href="https://juejin.im/post/6844903998412029959">前端性能和错误监控</a>的末尾，大家可以复制代码（HTML文件）在本地测试一下。</p>
<h3 id="">数据上报</h3>
<h4 id="">性能数据上报</h4>
<p>性能数据可以在页面加载完之后上报，尽量不要对页面性能造成影响。</p>
<pre><code class="language-js">window.onload = () =&gt; {
    // 在浏览器空闲时间获取性能及资源信息
    // https://developer.mozilla.org/zh-CN/docs/Web/API/Window/requestIdleCallback
    if (window.requestIdleCallback) {
        window.requestIdleCallback(() =&gt; {
            monitor.performance = getPerformance()
            monitor.resources = getResources()
        })
    } else {
        setTimeout(() =&gt; {
            monitor.performance = getPerformance()
            monitor.resources = getResources()
        }, 0)
    }
}
</code></pre>
<p>当然，你也可以设一个定时器，循环上报。不过每次上报最好做一下对比去重再上报，避免同样的数据重复上报。</p>
<h4 id="">错误数据上报</h4>
<p>我在DEMO里提供的代码，是用一个 <code>errors</code> 数组收集所有的错误，再在某一阶段统一上报（延时上报）。<br>
其实，也可以改成在错误发生时上报（即时上报）。这样可以避免在收集完错误延时上报还没触发，用户却已经关掉网页导致错误数据丢失的问题。</p>
<pre><code class="language-js">// 监听 js 错误
window.onerror = function(msg, url, row, col, error) {
    const data = {
        type: 'javascript',
        row: row,
        col: col,
        msg: error &amp;&amp; error.stack? error.stack : msg,
        url: url,
        // 错误发生的时间
        time: new Date().getTime(),
    }
    
    // 即时上报
    axios.post({ url: 'xxx', data, })
}
</code></pre>
<h3 id="spa">SPA</h3>
<p><code>window.performance</code> API 是有缺点的，在 SPA 切换路由时，<code>window.performance.timing</code> 的数据不会更新。<br>
所以我们需要另想办法来统计切换路由到加载完成的时间。<br>
拿 Vue 举例，一个可行的办法就是切换路由时，在路由的全局前置守卫 <code>beforeEach</code> 里获取开始时间，在组件的 <code>mounted</code> 钩子里执行 <code>vm.$nextTick </code> 函数来获取组件的渲染完毕时间。</p>
<pre><code class="language-js">router.beforeEach((to, from, next) =&gt; {
	store.commit('setPageLoadedStartTime', new Date())
})
</code></pre>
<pre><code class="language-js">mounted() {
	this.$nextTick(() =&gt; {
		this.$store.commit('setPageLoadedTime', new Date() - this.$store.state.pageLoadedStartTime)
	})
}
</code></pre>
<p>除了性能和错误监控，其实我们还可以做得更多。</p>
<h3 id="">用户信息收集</h3>
<h4 id="navigator">navigator</h4>
<p>使用 <code>window.navigator</code> 可以收集到用户的设备信息，操作系统，浏览器信息...</p>
<h4 id="uvuniquevisitor">UV（Unique visitor）</h4>
<p>是指通过互联网访问、浏览这个网页的自然人。访问您网站的一台电脑客户端为一个访客。00:00-24:00内相同的客户端只被计算一次。一天内同个访客多次访问仅计算一个UV。<br>
在用户访问网站时，可以生成一个随机字符串+时间日期，保存在本地。在网页发生请求时（如果超过当天24小时，则重新生成），把这些参数传到后端，后端利用这些信息生成 UV 统计报告。</p>
<h4 id="pvpageview">PV（Page View）</h4>
<p>即页面浏览量或点击量，用户每1次对网站中的每个网页访问均被记录1个PV。用户对同一页面的多次访问，访问量累计，用以衡量网站用户访问的网页数量。</p>
<h4 id="">页面停留时间</h4>
<p><strong>传统网站</strong><br>
用户在进入 A 页面时，通过后台请求把用户进入页面的时间捎上。过了 10 分钟，用户进入 B 页面，这时后台可以通过接口捎带的参数可以判断出用户在 A 页面停留了 10 分钟。<br>
<strong>SPA</strong><br>
可以利用 router 来获取用户停留时间，拿 Vue 举例，通过 <code>router.beforeEach</code> <code>destroyed</code> 这两个钩子函数来获取用户停留该路由组件的时间。</p>
<h4 id="">浏览深度</h4>
<p>通过 <code>document.documentElement.scrollTop</code> 属性以及屏幕高度，可以判断用户是否浏览完网站内容。</p>
<h4 id="">页面跳转来源</h4>
<p>通过 <code>document.referrer</code> 属性，可以知道用户是从哪个网站跳转而来。</p>
<h4 id="">小结</h4>
<p>通过分析用户数据，我们可以了解到用户的浏览习惯、爱好等等信息，想想真是恐怖，毫无隐私可言。</p>
<h3 id="">前端监控部署教程</h3>
<p>前面说的都是监控原理，但要实现还是得自己动手写代码。为了避免麻烦，我们可以用现有的工具 sentry 去做这件事。</p>
<p>sentry 是一个用 python 写的性能和错误监控工具，你可以使用 sentry 提供的服务（免费功能少），也可以自己部署服务。现在来看一下如何使用 sentry 提供的服务实现监控。</p>
<h4 id="">注册账号</h4>
<p>打开 <code>https://sentry.io/signup/</code> 网站，进行注册。</p>
<p><img src="https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/efae84d48d4143f895dfda7ef88a3354~tplv-k3u1fbpfcp-watermark.image" alt="efae84d48d4143f895dfda7ef88a3354~tplv-k3u1fbpfcp-watermark" width="600" height="400" loading="lazy"></p>
<p><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/f3846c1b87e84b6d90c771b0c8198068~tplv-k3u1fbpfcp-watermark.image" alt="f3846c1b87e84b6d90c771b0c8198068~tplv-k3u1fbpfcp-watermark" width="600" height="400" loading="lazy"></p>
<p>选择项目，我选的 Vue。</p>
<p><img src="https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/ed48cc42fa194f9cbca11550b471139e~tplv-k3u1fbpfcp-watermark.image" alt="ed48cc42fa194f9cbca11550b471139e~tplv-k3u1fbpfcp-watermark" width="600" height="400" loading="lazy"></p>
<h4 id="sentry">安装 sentry 依赖</h4>
<p>选完项目，下面会有具体的 sentry 依赖安装指南。</p>
<p><img src="https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/ddfb72545299440a940b650d577cf77f~tplv-k3u1fbpfcp-watermark.image" alt="ddfb72545299440a940b650d577cf77f~tplv-k3u1fbpfcp-watermark" width="600" height="400" loading="lazy"></p>
<p>根据提示，在你的 Vue 项目执行这段代码 <code>npm install --save @sentry/browser @sentry/integrations @sentry/tracing</code>，安装 sentry 所需的依赖。</p>
<p>再将下面的代码拷到你的 <code>main.js</code>，放在 <code>new Vue()</code> 之前。</p>
<pre><code class="language-js">import * as Sentry from "@sentry/browser";
import { Vue as VueIntegration } from "@sentry/integrations";
import { Integrations } from "@sentry/tracing";

Sentry.init({
  dsn: "xxxxx", // 这里是你的 dsn 地址，注册完就有
  integrations: [
    new VueIntegration({
      Vue,
      tracing: true,
    }),
    new Integrations.BrowserTracing(),
  ],

  // We recommend adjusting this value in production, or using tracesSampler
  // for finer control
  tracesSampleRate: 1.0,
});
</code></pre>
<p>然后点击第一步中的 <code>skip this onboarding</code>，进入控制台页面。</p>
<p>如果忘了自己的 DSN，请点击左边的菜单栏选择 <code>Settings</code> -&gt; <code>Projects</code> -&gt; 点击自己的项目 -&gt; <code>Client Keys(DSN)</code>。</p>
<h4 id="">创建第一个错误</h4>
<p>在你的 Vue 项目执行一个打印语句 <code>console.log(b)</code>。</p>
<p>这时点开 sentry 主页的 issues 一项，可以发现有一个报错信息 <code>b is not defined</code>：</p>
<p><img src="https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/24e28dd2f6034719bdfc35d8716b6ddf~tplv-k3u1fbpfcp-watermark.image" alt="24e28dd2f6034719bdfc35d8716b6ddf~tplv-k3u1fbpfcp-watermark" width="600" height="400" loading="lazy"></p>
<p>这个报错信息包含了错误的具体信息，还有你的 IP、浏览器信息等等。</p>
<p>但奇怪的是，我们的浏览器控制台并没有输出报错信息。</p>
<p>这是因为被 sentry 屏蔽了，所以我们需要加上一个选项 <code>logErrors: true</code>。</p>
<p><img src="https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/98dd3b5cb75649eaa2e2f0648ed5fd5b~tplv-k3u1fbpfcp-watermark.image" alt="98dd3b5cb75649eaa2e2f0648ed5fd5b~tplv-k3u1fbpfcp-watermark" width="600" height="400" loading="lazy"></p>
<p>然后再查看页面，发现控制台也有报错信息了：</p>
<p><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/aa26d5007bc24c919c56283dfe2c92b1~tplv-k3u1fbpfcp-watermark.image" alt="aa26d5007bc24c919c56283dfe2c92b1~tplv-k3u1fbpfcp-watermark" width="600" height="400" loading="lazy"></p>
<h4 id="sourcemap">上传 sourcemap</h4>
<p>一般打包后的代码都是经过压缩的，如果没有 sourcemap，即使有报错信息，你也很难根据提示找到对应的源码在哪。</p>
<p>下面来看一下如何上传 sourcemap。</p>
<p>首先创建 auth token。</p>
<p><img src="https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/5cf704d9cefa4e84941caaea3c096b69~tplv-k3u1fbpfcp-watermark.image" alt="5cf704d9cefa4e84941caaea3c096b69~tplv-k3u1fbpfcp-watermark" width="600" height="400" loading="lazy"></p>
<p><img src="https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/77dd5fde72634376a53a929fe3a06f66~tplv-k3u1fbpfcp-watermark.image" alt="77dd5fde72634376a53a929fe3a06f66~tplv-k3u1fbpfcp-watermark" width="600" height="400" loading="lazy"></p>
<p><img src="https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/bb9cff485e0b4120a6168040d81380bc~tplv-k3u1fbpfcp-watermark.image" alt="bb9cff485e0b4120a6168040d81380bc~tplv-k3u1fbpfcp-watermark" width="600" height="400" loading="lazy"></p>
<p><img src="https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/7b07fc31573d43b9ac59522b144bcd4e~tplv-k3u1fbpfcp-watermark.image" alt="7b07fc31573d43b9ac59522b144bcd4e~tplv-k3u1fbpfcp-watermark" width="600" height="400" loading="lazy"></p>
<p>这个生成的 token 一会要用到。</p>
<p>安装 <code>sentry-cli</code> 和 <code>@sentry/webpack-plugin</code>：</p>
<pre><code>npm install sentry-cli-binary -g
npm install --save-dev @sentry/webpack-plugin
</code></pre>
<p>安装完上面两个插件后，在项目根目录创建一个 <code>.sentryclirc</code> 文件（不要忘了在 <code>.gitignore</code> 把这个文件添加上，以免暴露 token），内容如下：</p>
<pre><code>[auth]
token=xxx

[defaults]
url=https://sentry.io/
org=woai3c
project=woai3c
</code></pre>
<p>把 xxx 替换成刚才生成的 token。</p>
<p><code>org</code> 是你的组织名称。</p>
<p><img src="https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/c01d66fa8c6144b2bcc8c2bad7be46f7~tplv-k3u1fbpfcp-watermark.image" alt="c01d66fa8c6144b2bcc8c2bad7be46f7~tplv-k3u1fbpfcp-watermark" width="600" height="400" loading="lazy"></p>
<p><code>project</code> 是你的项目名称，根据下面的提示可以找到。</p>
<p><img src="https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/a2bc1398957f4bc887365872e5c19724~tplv-k3u1fbpfcp-watermark.image" alt="a2bc1398957f4bc887365872e5c19724~tplv-k3u1fbpfcp-watermark" width="600" height="400" loading="lazy"></p>
<p><img src="https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/ab01b819e9044e3984406fa84bd9d0fa~tplv-k3u1fbpfcp-watermark.image" alt="ab01b819e9044e3984406fa84bd9d0fa~tplv-k3u1fbpfcp-watermark" width="600" height="400" loading="lazy"></p>
<p>在项目下新建 <code>vue.config.js</code> 文件，把下面的内容填进去：</p>
<pre><code class="language-js">const SentryWebpackPlugin = require('@sentry/webpack-plugin')

const config = {
    configureWebpack: {
        plugins: [
            new SentryWebpackPlugin({
                include: './dist', // 打包后的目录
                ignore: ['node_modules', 'vue.config.js', 'babel.config.js'],
            }),
        ],
    },
}

// 只在生产环境下上传 sourcemap
module.exports = process.env.NODE_ENV == 'production'? config : {}
</code></pre>
<p>填完以后，执行 <code>npm run build</code>，就可以看到 <code>sourcemap</code> 的上传结果了。</p>
<p><img src="https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/3b0b21bca0324f8a9e296d4d0aa782e0~tplv-k3u1fbpfcp-watermark.image" alt="3b0b21bca0324f8a9e296d4d0aa782e0~tplv-k3u1fbpfcp-watermark" width="600" height="400" loading="lazy"></p>
<p>我们再来看一下没上传 sourcemap 和上传之后的报错信息对比。</p>
<p><strong>未上传 sourcemap</strong></p>
<p><img src="https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/37b07f06365940269eac40ae953ea45e~tplv-k3u1fbpfcp-watermark.image" alt="37b07f06365940269eac40ae953ea45e~tplv-k3u1fbpfcp-watermark" width="600" height="400" loading="lazy"></p>
<p><img src="https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/9ad3ab31aded40bdb788e5a117b1611c~tplv-k3u1fbpfcp-watermark.image" alt="9ad3ab31aded40bdb788e5a117b1611c~tplv-k3u1fbpfcp-watermark" width="600" height="400" loading="lazy"></p>
<p><strong>已上传 sourcemap</strong></p>
<p><img src="https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/b9406ccfc46c42d1b9f499fb3f5e4280~tplv-k3u1fbpfcp-watermark.image" alt="b9406ccfc46c42d1b9f499fb3f5e4280~tplv-k3u1fbpfcp-watermark" width="600" height="400" loading="lazy"></p>
<p><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/0f066c98785e4597833b4a1600d27aa8~tplv-k3u1fbpfcp-watermark.image" alt="0f066c98785e4597833b4a1600d27aa8~tplv-k3u1fbpfcp-watermark" width="600" height="400" loading="lazy"></p>
<p>可以看到，上传 sourcemap 后的报错信息更加准确。</p>
<h4 id="">切换中文环境和时区</h4>
<p><img src="https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/7523abeeda95418880ff5683b1baf8a9~tplv-k3u1fbpfcp-watermark.image" alt="7523abeeda95418880ff5683b1baf8a9~tplv-k3u1fbpfcp-watermark" width="600" height="400" loading="lazy"></p>
<p><img src="https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/76b994e92044402c82dec0b2d1e5a762~tplv-k3u1fbpfcp-watermark.image" alt="76b994e92044402c82dec0b2d1e5a762~tplv-k3u1fbpfcp-watermark" width="600" height="400" loading="lazy"></p>
<p>选完刷新即可。</p>
<h4 id="">性能监控</h4>
<p><img src="https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/69b21fe514ee466292306c2ce4eaa672~tplv-k3u1fbpfcp-watermark.image" alt="69b21fe514ee466292306c2ce4eaa672~tplv-k3u1fbpfcp-watermark" width="600" height="400" loading="lazy"></p>
<p>打开 performance 选项，就能看到你每个项目的运行情况。具体的参数解释请看文档 <a href="https://docs.sentry.io/product/performance/">Performance Monitoring</a>。</p>
<h2 id="">性能优化</h2>
<p>性能优化主要分为两类：</p>
<ol>
<li>加载时优化</li>
<li>运行时优化</li>
</ol>
<p>例如压缩文件、使用 CDN 就属于加载时优化；减少 DOM 操作，使用事件委托属于运行时优化。</p>
<p>在解决问题之前，必须先找出问题，否则无从下手。所以在做性能优化之前，最好先调查一下网站的加载性能和运行性能。</p>
<h3 id="">手动检查</h3>
<h4 id="">检查加载性能</h4>
<p>一个网站加载性能如何主要看白屏时间和首屏时间。</p>
<ul>
<li>白屏时间：指从输入网址，到页面开始显示内容的时间。</li>
<li>首屏时间：指从输入网址，到页面完全渲染的时间。</li>
</ul>
<p>将以下脚本放在 <code>&lt;/head&gt;</code> 前面就能获取白屏时间。</p>
<pre><code class="language-html">&lt;script&gt;
	new Date() - performance.timing.navigationStart
&lt;/script&gt;
</code></pre>
<p>首屏时间比较复杂，得考虑有图片和没有图片的情况。</p>
<p>如果没有图片，则在 <code>window.onload</code> 事件里执行 <code>new Date() - performance.timing.navigationStart</code> 即可获取首屏时间。</p>
<p>如果有图片，则要在最后一个在首屏渲染的图片的 <code>onload</code> 事件里执行 <code>new Date() - performance.timing.navigationStart</code> 获取首屏时间，实施起来比较复杂，在这里限于篇幅就不说了。</p>
<h4 id="">检查运行性能</h4>
<p>配合 chrome 的开发者工具，我们可以查看网站在运行时的性能。</p>
<p>打开网站，按 F12 选择 performance，点击左上角的灰色圆点，变成红色就代表开始记录了。这时可以模仿用户使用网站，在使用完毕后，点击 stop，然后你就能看到网站运行期间的性能报告。如果有红色的块，代表有掉帧的情况；如果是绿色，则代表 FPS 很好。</p>
<p>另外，在 performance 标签下，按 ESC 会弹出来一个小框。点击小框左边的三个点，把 rendering 勾出来。</p>
<p><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/94fda5308b6f493cab3c48b692f3a7c6~tplv-k3u1fbpfcp-watermark.image" alt="94fda5308b6f493cab3c48b692f3a7c6~tplv-k3u1fbpfcp-watermark" width="600" height="400" loading="lazy"></p>
<p><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/0559bb7fa1164e4eab9a77354040e06a~tplv-k3u1fbpfcp-watermark.image" alt="0559bb7fa1164e4eab9a77354040e06a~tplv-k3u1fbpfcp-watermark" width="600" height="400" loading="lazy"></p>
<p>这两个选项，第一个是高亮重绘区域，另一个是显示帧渲染信息。把这两个选项勾上，然后浏览网页，可以实时的看到你网页渲染变化。</p>
<h3 id="">利用工具检查</h3>
<h4 id="">监控工具</h4>
<p>可以部署一个前端监控系统来监控网站性能，上一节中讲到的 sentry 就属于这一类。</p>
<h4 id="chromelighthouse">chrome 工具 Lighthouse</h4>
<p>如果你安装了 Chrome 52+ 版本，请按 F12 打开开发者工具。<br>
<img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/1dca9942cbd746d6ac3d25a9894fe9c0~tplv-k3u1fbpfcp-watermark.image" alt="1dca9942cbd746d6ac3d25a9894fe9c0~tplv-k3u1fbpfcp-watermark" width="600" height="400" loading="lazy"></p>
<p><img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/12234695750d422e9872d5c2d6a72834~tplv-k3u1fbpfcp-watermark.image" alt="12234695750d422e9872d5c2d6a72834~tplv-k3u1fbpfcp-watermark" width="600" height="400" loading="lazy"></p>
<p>它不仅会对你网站的性能打分，还会对 SEO 打分。</p>
<p><img src="https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/c709fb1a21aa46449c2a3633bda50464~tplv-k3u1fbpfcp-watermark.image" alt="c709fb1a21aa46449c2a3633bda50464~tplv-k3u1fbpfcp-watermark" width="600" height="400" loading="lazy"></p>
<p><a href="https://developers.google.com/web/tools/lighthouse">使用 Lighthouse 审查网络应用</a></p>
<h3 id="">如何做性能优化</h3>
<p>网上关于性能优化的文章和书籍多不胜数，但有很多优化规则已经过时了。所以我写了一篇性能优化文章<a href="https://zhuanlan.zhihu.com/p/121056616">前端性能优化 24 条建议(2020)</a>，分析总结出了 24 条性能优化建议，强烈推荐。</p>
<h2 id="">重构</h2>
<p><a href="https://book.douban.com/subject/30468597/">《重构2》</a>一书中对重构进行了定义：</p>
<blockquote>
<p>所谓重构（refactoring）是这样一个过程：在不改变代码外在行为的前提下，对代码做出修改，以改进程序的内部结构。重构是一种经千锤百炼形成的有条不紊的程序整理方法，可以最大限度地减小整理过程中引入错误的概率。本质上说，重构就是在代码写好之后改进它的设计。</p>
</blockquote>
<p>重构和性能优化有相同点，也有不同点。</p>
<p>相同的地方是它们都在不改变程序功能的情况下修改代码；不同的地方是重构为了让代码变得更加易读、理解，性能优化则是为了让程序运行得更快。</p>
<p>重构可以一边写代码一边重构，也可以在程序写完后，拿出一段时间专门去做重构。没有说哪个方式更好，视个人情况而定。</p>
<p>如果你专门拿一段时间来做重构，建议你在重构一段代码后，立即进行测试。这样可以避免修改代码太多，在出错时找不到错误点。</p>
<h3 id="">重构的原则</h3>
<ol>
<li>事不过三，三则重构。即不能重复写同样的代码，在这种情况下要去重构。</li>
<li>如果一段代码让人很难看懂，那就该考虑重构了。</li>
<li>如果已经理解了代码，但是非常繁琐或者不够好，也可以重构。</li>
<li>过长的函数，需要重构。</li>
<li>一个函数最好对应一个功能，如果一个函数被塞入多个功能，那就要对它进行重构了。</li>
</ol>
<h3 id="">重构手法</h3>
<p>在<a href="https://book.douban.com/subject/30468597/">《重构2》</a>这本书中，介绍了多达上百个重构手法。但我觉得有两个是比较常用的：</p>
<ol>
<li>提取重复代码，封装成函数</li>
<li>拆分太长或功能太多的函数</li>
</ol>
<h4 id="">提取重复代码，封装成函数</h4>
<p>假设有一个查询数据的接口 <code>/getUserData?age=17&amp;city=beijing</code>。现在需要做的是把用户数据：<code>{ age: 17, city: 'beijing' }</code> 转成 URL 参数的形式：</p>
<pre><code class="language-js">let result = ''
const keys = Object.keys(data)  // { age: 17, city: 'beijing' }
keys.forEach(key =&gt; {
    result += '&amp;' + key + '=' + data[key]
})

result.substr(1) // age=17&amp;city=beijing
</code></pre>
<p>如果只有这一个接口需要转换，不封装成函数是没问题的。但如果有多个接口都有这种需求，那就得把它封装成函数了：</p>
<pre><code class="language-js">function JSON2Params(data) {
    let result = ''
    const keys = Object.keys(data)
    keys.forEach(key =&gt; {
        result += '&amp;' + key + '=' + data[key]
    })

    return result.substr(1)
}
</code></pre>
<h4 id="">拆分太长或功能太多的函数</h4>
<p>假设现在有一个注册功能，用伪代码表示：</p>
<pre><code class="language-js">function register(data) {
    // 1. 验证用户数据是否合法
    /**
     * 验证账号
     * 验证密码
     * 验证短信验证码
     * 验证身份证
     * 验证邮箱
     */

    // 2. 如果用户上传了头像，则将用户头像转成 base64 码保存
    /**
     * 新建 FileReader 对象
     * 将图片转换成 base64 码
     */

    // 3. 调用注册接口
    // ...
}
</code></pre>
<p>这个函数包含了三个功能，验证、转换、注册。其中验证和转换功能是可以提取出来单独封装成函数的：</p>
<pre><code class="language-js">function register(data) {
    // 1. 验证用户数据是否合法
    // verify()

    // 2. 如果用户上传了头像，则将用户头像转成 base64 码保存
    // tobase64()

    // 3. 调用注册接口
    // ...
}
</code></pre>
<p>如果你对重构有兴趣，强烈推荐你阅读<a href="https://book.douban.com/subject/30468597/">《重构2》</a>这本书。</p>
<p>参考资料：</p>
<ul>
<li><a href="https://book.douban.com/subject/30468597/">《重构2》</a></li>
</ul>
<h2 id="">总结</h2>
<p>写这篇文章主要是为了对我这一年多工作经验作总结，因为我基本上都在研究前端工程化以及如何提升团队的开发效率。希望这篇文章能帮助一些对前端工程化没有经验的新手，通过这篇文章入门前端工程化。</p>
<p>如果这篇文章对你有帮助，请点一下赞，感激不尽。</p>
<h3 id="">求职启事</h3>
<p>本人具有三年+前端工作经验，32岁，高中学历，现寻求天津、北京地区的前端工作机会。</p>
<p>下面是我掌握的一些技能：</p>
<ol>
<li>熟练掌握 HTML、CSS、JavaScript。</li>
<li>熟练掌握 Vue 全家桶并研究过 Vue1.0 源码及 Vue3.0 部分源码。</li>
<li>使用 nodejs 写过脚本和<a href="https://github.com/woai3c/node-blog">个人博客</a>，没有开发过企业应用。</li>
<li>学习计算机原理并实现一个简单的 cpu 和内存模块运行在模拟器上（<a href="https://github.com/woai3c/nand2tetris">github 项目地址</a>）。</li>
<li>学习操作系统并做实验实现了一个简单的内核（<a href="https://github.com/woai3c/MIT6.828">github 项目地址</a>）。</li>
<li>学习编译原理写过一个简单编译器（<a href="https://github.com/woai3c/nand2tetris">github 项目地址</a>）。</li>
<li>对计算机网络应用层和传输层的知识比较了解。</li>
<li>数据结构与算法有学习过，还刷了 300+ 道 leetcode，但效果不是很好。</li>
</ol>
<h4 id="">社交网站</h4>
<ul>
<li><a href="https://github.com/woai3c">Github</a></li>
<li><a href="https://www.zhihu.com/people/tan-guang-zhi-19">知乎</a></li>
</ul>
<p>如果您觉得我的条件还可以，可以私信我或在评论区留言，谢谢。</p>
<!--kg-card-end: markdown--> ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
