如何用测试驱动开发的方式在JavaScript中创建一个对象验证器

119 阅读10分钟

简介

测试驱动开发(TDD)似乎是一个伟大的概念,但在你看到它的实际应用之前,很难完全理解和欣赏它。在这篇博文中,我们将使用TDD实现一个JavaScript对象验证器。

测试驱动开发的快速入门

TDD通过首先编写测试,然后编写满足这些测试的代码,颠覆了很多 "传统 "的软件开发过程。一旦测试通过,代码就会被重构,以确保它是可读的,与代码库的其他部分使用一致的风格,是高效的,等等。我喜欢用 "红色、绿色、重构"来记住这个过程。

红色❌ -> 绿色✔ -> 重构 ♻

  1. 红色❌ - 写一个测试。运行你的测试。新的测试失败了,因为你还没有写任何代码来通过测试。
  2. 绿色 ✔- 写出能通过测试的代码(以及之前所有的测试)。不要耍小聪明,只要写出测试通过的代码就可以了。
  3. 重构- 重构你的代码!重构的原因有很多,例如效率、代码风格和可读性。确保你的代码在重构的过程中仍然能通过测试。

这个过程的好处是,只要你的测试能代表你的代码的用例,你现在开发的代码(a)不包括任何镀金,(b)将来每次运行测试时都会被测试。

我们的TDD候选者:一个对象验证器

我们的TDD候选人是一个对象验证函数。这是一个函数,它将接受一个对象和一些标准作为输入。最初,我们的要求是这样的。

  • 验证器将接受两个参数:一个要验证的对象和一个标准的对象。
  • 验证器将返回一个带有布尔值valid 属性的对象,表明该对象是有效的 (true) 还是无效的 (false)。

稍后,我们将添加一些更复杂的标准。

设置我们的环境

在这个练习中,让我们创建一个新的目录并安装jest ,这是我们要使用的测试框架。

mkdir object-validator
cd object-validator
yarn add jest@24.9.0

**注意:**你之所以特别将jest安装在24.9.0版本,是为了确保你的版本与我在本教程中使用的版本一致。

最后一条命令将为我们创建一个package.json 文件。在该文件中,让我们修改脚本部分,使我们能够在运行yarn test 时用--watchAll 标志运行jest。这意味着当我们对文件进行修改时,所有的测试都会重新运行。

我们的package.json 文件现在应该是这样的。

{
  "scripts": {
    "test": "jest"
  },
  "dependencies": {
    "jest": "24.9.0"
  }
}

接下来,创建两个文件:validator.jsvalidator.test.js 。前者将包含验证器的代码,后者将包含我们的测试。(默认情况下,jest会在以.test.js 结尾的文件中搜索测试)。

创建一个空的验证器和初始测试

在我们的validator.js 文件中,让我们先简单地导出null ,这样我们就有东西可以导入我们的测试文件中。

validator.js

module.exports = null;

validator.test.js

const validator = require('./validator');

一个初始测试

在我们的初始测试中,我们将检查我们的验证器在没有提供标准的情况下是否认为一个对象有效。现在让我们来写这个测试。

validator.test.js

const validator = require('./validator');

describe('validator', () => {
  it('should return true for an object with no criteria', () => {
    const obj = { username: 'sam21' };
    expect(validator(obj, null).valid).toBe(true);
  });
});

现在我们运行这个测试!注意,我们实际上还没有为我们的validator 函数编写任何代码,所以这个测试最好是失败。

不要跳过这一步!跳过红-绿-重构循环中的 "红色 "部分总是很诱人的,但你应该总是花时间先让你的测试失败。这是为了让你能够测试你的测试......换句话说,你需要确认你的测试在应该失败的时候失败,否则它就不能正确测试你的软件。

yarn test

如果一切顺利,你应该看到我们的测试失败了。

validator
  ✕ should return true for an object with no criteria (2ms)

使测试通过

现在我们已经确认测试失败,让我们让它通过。为了做到这一点,我们将简单地让我们的validator.js 文件导出一个函数,返回所需的对象。

验证器.js

const validator = () => {
  return { valid: true };
};

module.exports = validator;

我们的测试应该仍然在控制台中运行,所以如果我们看一下那里,我们应该看到我们的测试现在已经通过了!

validator
  ✓ should return true for an object with no criteria

继续循环...

让我们再添加几个测试。我们知道,我们想根据标准来通过或失败一个对象。现在我们将添加两个测试来实现这一点。

验证器.test.js

it('should pass an object that meets a criteria', () => {
  const obj = { username: 'sam123' };
  const criteria = obj => obj.username.length >= 6
  };
  expect(validator(obj, criteria).valid).toBe(true);
});
it('should fail an object that meets a criteria', () => {
  const obj = { username: 'sam12' };
  const criteria = obj => obj.username.length >= 6,
  };
  expect(validator(obj, criteria).valid).toBe(false);
});

现在我们运行我们的测试,以确保这两个新的测试失败......但其中一个没有!这在TDD中实际上是相当正常的。这在TDD中实际上是相当正常的,而且经常会发生,因为通用的解决方案巧合地与更具体的要求相匹配。为了解决这个问题,我建议暂时改变validator.js 中的返回对象,以验证已经通过的测试确实可以失败。例如,如果我们从验证器函数中返回{ valid: null } ,我们可以显示每个测试都失败。

validator
  ✕ should return true for an object with no criteria (4ms)
  ✕ should pass an object that meets a criteria (1ms)
  ✕ should fail an object that meets a criteria

现在,让我们来通过这些测试。我们将更新我们的验证器函数,以返回通过objcriteria 的结果。

validator.js

const validator = (obj, criteria) => {
  if (!criteria) {
    return { valid: true };
  }
  return { valid: criteria(obj) };
};

module.exports = validator;

我们的测试都通过了!我们应该考虑在这个时候进行重构,但在这一点上我没有看到什么机会。让我们继续创建测试。现在,我们将考虑到我们需要能够评估多个标准的事实。

it('should return true if all criteria pass', () => {
  const obj = {
    username: 'sam123',
    password: '12345',
    confirmPassword: '12345',
  };
  const criteria = [
    (obj) => obj.username.length >= 6,
    (obj) => obj.password === obj.confirmPassword,
  ];
  expect(validator(obj, criteria).valid).toBe(true);
});
it('should return false if only some criteria pass', () => {
  const obj = {
    username: 'sam123',
    password: '12345',
    confirmPassword: '1234',
  };
  const criteria = [
    (obj) => obj.username.length >= 6,
    (obj) => obj.password === obj.confirmPassword,
  ];
  expect(validator(obj, criteria).valid).toBe(false);
});

我们的两个新测试失败了,因为我们的validator 函数不希望criteria 是一个数组。我们可以用几种方法来处理这个问题:我们可以让用户提供一个函数或一个函数数组作为标准,然后在我们的validator 函数中处理每个情况。既然如此,我宁愿我们的validator 函数有一个一致的接口。因此,我们将只是把标准当作一个数组,并根据需要修正以前的任何测试。

下面是我们为使测试通过而进行的第一次尝试。

validator.js

const validator = (obj, criteria) => {
  if (!criteria) {
    return { valid: true };
  }
  for (let i = 0; i < criteria.length; i++) {
    if (!criteria[i](obj)) {
      return { valid: false };
    }
  }
  return { valid: true };
};

module.exports = validator;

我们的新测试通过了,但现在我们把criteria 作为一个函数的旧测试失败了。让我们继续更新这些测试,以确保criteria 是一个数组。

validator.test.js (固定测试)

it('should pass an object that meets a criteria', () => {
  const obj = { username: 'sam123' };
  const criteria = [(obj) => obj.username.length >= 6];
  expect(validator(obj, criteria).valid).toBe(true);
});
it('should fail an object that meets a criteria', () => {
  const obj = { username: 'sam12' };
  const criteria = [(obj) => obj.username.length >= 6];
  expect(validator(obj, criteria).valid).toBe(false);
});

我们所有的测试都通过了,回到了绿色的状态!这一次,我认为我们可以合理地重构我们的代码。我们想起我们可以使用every 数组方法,这与我们团队的风格一致。

validator.js

const validator = (obj, criteria) => {
  if (!criteria) {
    return { valid: true };
  }
  const valid = criteria.every((criterion) => criterion(obj));
  return { valid };
};

module.exports = validator;

干净多了,而且我们的测试仍然通过。请注意,由于我们进行了彻底的测试,我们可以对我们的重构有多大的信心!

处理一个相对较大的需求变化

我们对我们的验证器的成型很满意,但用户测试表明,我们真的需要能够支持基于验证的错误信息。此外,我们需要按字段名聚合错误信息,这样我们就可以在正确的输入字段旁边向用户显示它们。

我们决定,我们的输出对象需要类似于以下的形状。

{
  valid: false,
  errors: {
    username: ["Username must be at least 6 characters"],
    password: [
      "Password must be at least 6 characters",
      "Password must match password confirmation"
    ]
  }
}

让我们写一些测试来适应新的功能。我们很快意识到criteria ,需要一个对象的数组,而不是一个函数的数组。

验证器.test.js

it("should contain a failed test's error message", () => {
  const obj = { username: 'sam12' };
  const criteria = [
    {
      field: 'username',
      test: (obj) => obj.username.length >= 6,
      message: 'Username must be at least 6 characters',
    },
  ];
  expect(validator(obj, criteria)).toEqual({
    valid: false,
    errors: {
      username: ['Username must be at least 6 characters'],
    },
  });
});

我们现在运行我们的测试,发现最后一个测试失败了。让我们让它通过吧。

validator.test.js

const validator = (obj, criteria) => {
  if (!criteria) {
    return { valid: true };
  }
  const errors = {};
  for (let i = 0; i < criteria.length; i++) {
    if (!criteria[i].test(obj)) {
      if (!Array.isArray(errors[criteria[i].field])) {
        errors[criteria[i].field] = [];
      }
      errors[criteria[i].field].push(criteria[i].message);
    }
  }

  return {
    valid: Object.keys(errors).length === 0,
    errors,
  };
};

module.exports = validator;

现在,第一个测试和最后一个测试通过了,但其他的都失败了。这是因为我们改变了我们的criteria 输入的形状。

validator
  ✓ should return true for an object with no criteria (2ms)
  ✕ should pass an object that meets a criteria (3ms)
  ✕ should fail an object that meets a criteria
  ✕ should return true if all criteria pass
  ✕ should return false if only some criteria pass
  ✓ should contain a failed test's error message

既然我们知道最后一个测试案例中的criteria 实现是正确的,让我们把中间的四个案例更新为通过。同时,让我们为我们的标准对象创建变量,以重复使用它们。

验证器.test.js

const validator = require('./validator');

const usernameLength = {
  field: 'username',
  test: (obj) => obj.username.length >= 6,
  message: 'Username must be at least 6 characters',
};

const passwordMatch = {
  field: 'password',
  test: (obj) => obj.password === obj.confirmPassword,
  message: 'Passwords must match',
};

describe('validator', () => {
  it('should return true for an object with no criteria', () => {
    const obj = { username: 'sam21' };
    expect(validator(obj, null).valid).toBe(true);
  });
  it('should pass an object that meets a criteria', () => {
    const obj = { username: 'sam123' };
    const criteria = [usernameLength];
    expect(validator(obj, criteria).valid).toBe(true);
  });
  it('should fail an object that meets a criteria', () => {
    const obj = { username: 'sam12' };
    const criteria = [usernameLength];
    expect(validator(obj, criteria).valid).toBe(false);
  });
  it('should return true if all criteria pass', () => {
    const obj = {
      username: 'sam123',
      password: '12345',
      confirmPassword: '12345',
    };
    const criteria = [usernameLength, passwordMatch];
    expect(validator(obj, criteria).valid).toBe(true);
  });
  it('should return false if only some criteria pass', () => {
    const obj = {
      username: 'sam123',
      password: '12345',
      confirmPassword: '1234',
    };
    const criteria = [usernameLength, passwordMatch];
    expect(validator(obj, criteria).valid).toBe(false);
  });
  it("should contain a failed test's error message", () => {
    const obj = { username: 'sam12' };
    const criteria = [usernameLength];
    expect(validator(obj, criteria)).toEqual({
      valid: false,
      errors: {
        username: ['Username must be at least 6 characters'],
      },
    });
  });
});

如果我们检查一下我们的测试,它们都通过了!

validator
  ✓ should return true for an object with no criteria
  ✓ should pass an object that meets a criteria (1ms)
  ✓ should fail an object that meets a criteria
  ✓ should return true if all criteria pass
  ✓ should return false if only some criteria pass (1ms)
  ✓ should contain a failed test's error message

看起来不错。现在让我们考虑一下如何进行重构。我当然不喜欢我们解决方案中的嵌套if 语句,当我们的代码仍然倾向于数组方法时,我们又回到了使用for 循环。这里有一个对我们来说更好的版本。

const validator = (obj, criteria) => {
  const cleanCriteria = criteria || [];

  const errors = cleanCriteria.reduce((messages, criterion) => {
    const { field, test, message } = criterion;
    if (!test(obj)) {
      messages[field]
        ? messages[field].push(message)
        : (messages[field] = [message]);
    }
    return messages;
  }, {});

  return {
    valid: Object.keys(errors).length === 0,
    errors,
  };
};

module.exports = validator;

我们的测试仍然通过,我们对我们重构后的validator 代码的外观非常满意当然,我们可以而且应该继续建立我们的测试用例,以确保我们能够处理多个字段和每个字段的多个错误,但我将让你继续自己的探索!

结论

测试驱动开发使我们有能力在实际编写代码之前定义我们的代码需要的功能。它允许我们有条不紊地测试和编写代码,并给我们的重构带来了巨大的信心。像任何方法论一样,TDD并不完美。如果你不能确保你的测试首先失败,它就很容易出错。此外,如果你对你所写的测试不彻底、不严格,它可能会给人一种错误的自信感。