Skip to content

Files

Latest commit

00d6687 · Oct 25, 2021

History

History
815 lines (572 loc) · 42.2 KB

File metadata and controls

815 lines (572 loc) · 42.2 KB

三、用户体验编程

在本章中,我们将研究编程中几个经常被忽略但有价值的方面,主要是测试、用户体验和依赖关系图。虽然这些话题可能感觉与他们无关;这是一个坚实而实用的基础,从中你可以评估本书第二部分的技巧。

本章将介绍以下主题:

  • 为人类优化
  • 名为单元测试的安全毯
  • 试验诱导损伤
  • 使用 Godepgraph 可视化包依赖关系

发现良好的用户体验

好的用户体验不需要预测。它也不需要从一些有经验的古鲁那里传下来。事实上,经验的问题在于,今天对你来说容易、简单、显而易见的事情与上个月、去年或刚开始的时候大不相同。

好的用户体验可以通过逻辑、持久性和实践来发现。要了解一个好的用户体验对您的用户来说是什么样子的,您可以应用我的用户体验发现调查。

问自己以下四个问题:

  • 用户是谁?
  • 你的用户能做什么?
  • 用户为什么要使用您的代码?
  • 您的用户希望如何使用它?

技术要求

在本章中,您需要对围棋有一个基本的了解。

本章所有代码可在上找到 https://github.com/PacktPublishing/Hands-On-Dependency-Injection-in-Go/tree/master/ch03

为人类优化

近年来,我们看到 UX 一词的兴起,它代表用户体验。UX 的核心是可用性,了解用户并精心设计交互和界面,使其更直观或更自然地使用。

用户体验通常指的是客户,这是有道理的,毕竟,钱在哪里。然而,我们程序员错过了一些相当重要的东西。让我问你,你写的代码的用户是谁?而不是使用软件本身的客户。代码的用户是您的同事和您的未来版本。你想让他们的生活更轻松吗?换一种方式来说,您是愿意把未来花在找出一段代码的用途上,还是愿意扩展系统?钱就在那里。作为程序员,我们获得的报酬是交付功能,而不是漂亮的代码,具有良好用户体验的代码能够更快地交付功能,并且风险更小。

用户体验对 Go 代码意味着什么?

UX 对于 Go 代码意味着什么?简短的版本是,我们应该编写代码,在任何有能力的程序员快速阅读之后,我们就可以理解代码的总体意图。

听起来有点像在挥手吗?是的,可能是在挥手。这是在任何创造性努力中解决问题的标准问题;当你看到它时你就知道了,当它不存在时你就感觉到了。可能难以定义的主要原因是能力的定义因团队成员和环境的不同而有很大差异。类似地,代码通常很难实现的原因是,对于作者来说,代码本身比其他任何人都更有意义。

但首先,让我们看看一些简单的原则,从正确的方向开始。

从简单开始——只有在必要时才变得复杂

作为程序员,我们应该始终努力使事情保持简单,并在没有其他方法时求助于复杂性。让我们看看这一原则的实际应用。尝试确定下一个示例在三秒或更短的时间内执行的操作:

func NotSoSimple(ID int64, name string, age int, registered bool) string {
  out := &bytes.Buffer{}
  out.WriteString(strconv.FormatInt(ID, 10))
  out.WriteString("-")
  out.WriteString(strings.Replace(name, " ", "_", -1))
  out.WriteString("-")
  out.WriteString(strconv.Itoa(age))
  out.WriteString("-")
  out.WriteString(strconv.FormatBool(registered))
  return out.String()
}

这个怎么样:

func Simpler(ID int64, name string, age int, registered bool) string {
  nameWithNoSpaces := strings.Replace(name, " ", "_", -1)
  return fmt.Sprintf("%d-%s-%d-%t", ID, nameWithNoSpaces, age, registered)
}

将第一个代码中包含的方法应用于整个系统几乎肯定会使其运行得更快,但这不仅可能需要更长的时间来编写代码,而且更难阅读,因此也更难维护和扩展。

有时,您需要从代码中提取极端的性能,但最好等到无法避免时再给自己增加额外的复杂性。

应用足够的抽象

过度抽象会导致过度的精神负担和过度打字。虽然有些人可能会认为,任何可以在以后交换或扩展的代码片段都应该进行抽象,但我认为应该采用更实用的方法。实现足够多的功能,以实现我们的业务价值,然后根据需要进行重构。请看以下代码:

type myGetter interface {
  Get(url string) (*http.Response, error)
}

func TooAbstract(getter myGetter, url string) ([]byte, error) {
  resp, err := getter.Get(url)
  if err != nil {
    return nil, err
  }
  defer resp.Body.Close()

  return ioutil.ReadAll(resp.Body)
}

将前面的代码与以下常见概念的用法进行比较:

func CommonConcept(url string) ([]byte, error) {
  resp, err := http.Get(url)
  if err != nil {
    return nil, err
  }
  defer resp.Body.Close()

  return ioutil.ReadAll(resp.Body)
}

遵循行业、团队和语言惯例

当概念、变量和函数名遵循约定时,它们都才有意义。问问你自己,如果你正在研究一个关于汽车的系统,你会期望一个叫做flower的变量是什么?

编码风格可以说是正确的。多年来,我一直是支架放置标签对空格战争的一部分,但当切换到 Go 时,所有这些都改变了。有一个固定的、记录在案的、易于复制的样式运行gofmt,问题已经解决。还有一些地方你会受伤。由于语言中存在未经检查的异常,您可能会尝试使用 Go 的panic()短语;虽然可能,但这是官方代码审查评论 wiki(中明确禁止的几种约定之一 https://github.com/golang/go/wiki/CodeReviewComments

团队约定有点难以定义,有时可能难以遵循。channel类型的变量应称为resultresultCh还是resultChan?我已经看过,也可能写过这三本书。

错误日志记录呢?一些团队喜欢在错误触发点记录错误,而其他团队则喜欢在调用堆栈的顶部记录错误。我有一个偏好,我相信你们也有,但我还没有看到一个压倒性的令人信服的论据。

只导出必须导出的内容

当您对导出的 API 小心而吝啬时,就会发生许多好事。主要是,它变得更容易让别人理解;当一个方法具有较少的参数时,自然更容易理解。请看以下代码:

NewPet("Fido", true)

true是什么意思?如果不打开函数或文档,很难判断。但是,如果我们执行以下操作,该怎么办:

NewDog("Fido")

在这种情况下,目的是明确的,不太可能出现错误,另外,封装也得到了改进。

类似地,具有较少方法的接口和结构以及具有对象的包都更容易理解,并且更有可能具有更明确的用途。让我们看另一个例子:

type WideFormatter interface {
  ToCSV(pets []Pet) ([]byte, error)
  ToGOB(pets []Pet) ([]byte, error)
  ToJSON(pets []Pet) ([]byte, error)
}

将上述代码与以下代码进行比较:

type ThinFormatter interface {
  Format(pets []Pet) ([]byte, error)
}

type CSVFormatter struct {}

func (f CSVFormatter) Format(pets []Pet) ([]byte, error) {
  // convert slice of pets to CSV
}

是的,在这两种情况下,结果都是更多的代码。更简单的代码,但仍然需要更多的代码。为用户提供更好的用户体验通常会增加一点成本,但用户的生产率提高是倍增的。考虑到这样一个事实,在许多情况下,您编写的代码的用户之一是未来的您,您可以说,现在一点额外的工作可以为您将来节省大量的工作。

继续沿着寻找未来我的路线,这种方法提供的第二个优势是更容易改变你的想法。导出函数或类型后,即可使用;一旦使用,就必须对其进行维护,并且需要付出更多的努力才能进行更改。这种方法使这种改变变得更容易。

积极运用单一责任原则

正如我们在第 2 章中看到的,Go的实体设计原则,应用单一责任原则SRP)鼓励对象更简洁、更连贯,因此更容易理解。

用户是谁?

很多时候,答案将是未来的我和我的同事。你的未来我将是一个更好、更聪明、更英俊的你。另一方面,你的同事更难预测。如果有帮助的话,我们可以避免考虑那些聪明、奇妙的东西;希望无论我们做什么,他们都能理解。另一方面,实习生将更难预测。如果我们能让我们的代码对他们有意义,那么对其他人来说就没问题了。

如果您曾经有机会编写公司范围或通用的软件库,那么这个答案将变得非常困难。一般来说,您希望目标较低,只有在没有其他选择的情况下才偏离标准和直接的格式。

你的用户能做什么?

现在我们已经清楚用户是谁,我们可以更好地了解他们的世界观。你和你的用户之间,甚至你和未来的你之间,在技能、经验和领域知识方面可能存在巨大的差异。这是大多数技术工具和软件库失败的地方。回想一下你刚开始围棋的时候。你的代码是什么样子的?Go 中是否有您尚未使用的语言功能?就个人而言,我来自 Java 背景,因此,我带着一些先入为主的想法进入该领域:

  • 我认为线程是昂贵的(而 goroutine 就是线程)
  • 我认为一切都必须在一个结构中
  • 习惯于显式接口意味着我对使用接口分离原则ISP)或依赖倒置原则DSP的热情不如现在
  • 我不明白渠道的力量
  • 路过兰达斯让我大吃一惊

随着时间的推移,我看到这类事情一次又一次地出现,特别是在代码评审评论中。有一种非常有效的方法可以回答这个问题:*用户能做什么?*写一个例子,问你的同事以下问题:

  • 这有什么用?
  • 你会怎么做?
  • 您希望这个函数做什么?

如果你没有任何可以测试的用户,另一个选择是问你自己,*还有什么类似的存在?*我不是建议你追随别人的错误。这里的基本理论是,如果存在其他东西,并且您的用户对它感到满意,那么,如果您的类似,他们将不必学习使用它。当我使用 lambdas 时,这可能是最好的说明。来自功能背景的同事对此很满意,但那些来自面向对象背景的同事发现它要么有些混乱,要么就是不够直观。

用户为什么要使用您的代码?

用户为什么要使用您的代码这个问题的答案可能很长而且多种多样。如果是,您可能想返回并重新阅读SRP部分。除了能够将代码分成更小、更简洁的块之外,我们还需要列出一个列表。我们将对该列表应用 80/20 规则。通常,80%的使用来自 20%的用例。让我用一个例子来说明这一点。

考虑一个自动自动柜员机(To.t2,ATM,To.T3)。其用例列表可能如下所示:

  • 取款

  • 存款

  • 核对余额

  • 更改 PIN 码

  • 转账

  • 存款支票

我估计至少有 80%的人使用自动取款机的目的是取款。那么我们能用这些信息做什么呢?我们可以优化接口,使最常见的用例尽可能方便。在 ATM 的情况下,它可以简单地将取款功能放在顶部的第一个屏幕上,这样用户就不必搜索取款功能。现在我们了解了用户想要达到的目标,我们可以在此基础上考虑他们如何使用它。

他们希望如何使用它?

虽然 ATM 的例子很清楚,但它是一个系统,因此您可能想知道如何将其应用于低级概念,例如函数。让我们看一个例子:

// PetFetcher searches the data store for pets whose name matches
// the search string.
// Limit is optional (default is 100). Offset is optional (default 0).
// sortBy is optional (default name). sortAscending is optional
func PetFetcher(search string, limit int, offset int, sortBy string, sortAscending bool) []Pet {
  return []Pet{}
}

看起来没问题吧?问题在于,大多数用法如下所示:

results := PetFetcher("Fido", 0, 0, "", true)

如您所见,大多数情况下,我们并不需要所有这些返回值,并且许多输入被忽略。

解决这种情况的第一步是查看代码中未充分使用的部分,并问问自己,我们真的需要它们吗?如果它们仅为测试而存在,则意味着它们是测试诱导损伤,我们将在本章后面介绍。

如果它们存在于一些不经常使用但引人注目的用例中,那么我们可以用另一种方式来解决它。第一种选择是将功能分成多个部分;这将允许用户只采用他们需要的复杂性。第二个选项是将配置合并到一个对象中,允许用户忽略他们不使用的部分。

在这两种方法中,我们都提供了合理的默认值,通过允许用户只担心他们需要什么,减少了功能的心理负担。

何时妥协

拥有良好的用户体验是一个理想的目标,但不是必须的。总会有一些情况需要对用户体验进行妥协。第一种可能也是最常见的情况是团队进化。

随着团队的发展和对围棋越来越有经验,不可避免地会发现一些早期的软件模式不再有效。这些可能包括使用全局、紧急和从环境变量加载配置,甚至是何时使用函数而不是对象。随着团队的发展,他们对好软件和标准或直观软件的定义也在不断发展。

第二,在很多情况下,一个被滥用的糟糕用户体验的借口是性能。正如我们在本章的早期示例中所看到的,通常可以编写更快的代码,但更快的代码通常更难理解。这里最好的选择是先为人类优化它,然后,只有当系统被证明不够快时,才为速度优化它。即使如此,这些优化也应该有选择地应用到系统的那些部分,通过测量,这些部分被证明是值得重构的,并且需要付出低于理想用户体验的长期成本。

最后一种情况是能见度;有时候,你就是看不到什么是好的用户体验。在这些情况下,更有效的选择是实现,然后根据使用情况和出现的任何不便进行迭代重构。

关于用户体验编码的最后思考

程序员的时间,你的时间,是昂贵的;您应该优先于 CPU 时间来保存它。开发人员的用户体验具有挑战性,因为我们需要解决问题并交付有用的软件。但是,可以节省程序员的时间。请记住以下几点:

  • 使某些东西更具可配置性并不能使其更具可用性,反而会使其使用更加混乱
  • 为所有用例设计会使代码对每个人都不方便
  • 用户能力和期望在您的代码如何被理解和采用方面起着重要作用

也许最相关的是,改变用户体验以匹配用户总是比改变用户体验更好、更容易。

名为单元测试的安全毯

许多人会告诉你,你必须为你的代码编写单元测试;他们确保你没有 bug。他们真的一点也不这样做。我编写单元测试也不是因为有人告诉我必须这样做。我为他们为我做的事情编写单元测试。单元测试正在授权。他们实际上减少了我必须做的工作量。也许这些不是你以前听过的理由。让我们更详细地探讨它们。

单元测试给了你重构的自由和信心:我喜欢重构,也许有点太多了,但那是另一个话题。重构使我能够尝试不同风格的代码、实现和用户体验。通过适当地进行单元测试,我可以充满冒险精神,并且相信我不会无意中破坏任何东西。它们还可以让你有勇气尝试新技术、库或编码技术。

现有的单元测试使添加新特性变得更容易:正如我们前面提到的,添加新特性确实会带来一些风险,我们可能会破坏某些东西。测试到位提供了一个安全网,让我们不那么在意已经存在的东西,而更多地关注添加新功能。这似乎有悖常理,但单元测试实际上会让您更快。随着系统的扩展,拥有一套安全的单元测试可以让您自信地继续进行,而不必担心可能会破坏的东西。

单元测试防止重复回归:没有办法避免回归糟糕透顶。这会让你看起来很糟糕,会给你带来额外的工作,但这是会发生的。我们最大的希望是不要重复修复同一个 bug。虽然测试确实阻止了一些回归,但它们无法阻止一切。通过编写一个由于错误而失败的测试,然后修复错误,我们实现了两件事。首先,我们知道错误何时被修复,因为测试通过了。其次,错误不会再次发生。

单元测试记录您的意图:虽然我并不是说测试可以取代文档,但它们是明确的、可执行的表达式,表达了您在编写代码时的意图。在团队中工作时,这是一种非常理想的品质。它允许您处理系统的任何部分,而不必担心破坏他人编写的代码,甚至可能完全理解它。

单元测试通过依赖关系记录您的需求:在本书的第二部分中,我们将介绍一些将 DI 应用于现有代码库的示例。这个过程的一个重要部分将包括将功能分组并提取到抽象中。这些抽象自然成为工作单元。然后对每个单元进行单独和隔离测试。因此,这些测试更加集中,并且更易于编写和维护。

此外,对使用 DI 的代码的测试通常将重点放在该函数如何使用依赖项以及如何对依赖项做出反应上。这些测试有效地定义了依赖关系的需求契约,并有助于防止回归。让我们看一个例子:

type Loader interface {
  Load(ID int) (*Pet, error)
}

func TestLoadAndPrint_happyPath(t *testing.T) {
  result := &bytes.Buffer{}
  LoadAndPrint(&happyPathLoader{}, 1, result)
  assert.Contains(t, result.String(), "Pet named")
}

func TestLoadAndPrint_notFound(t *testing.T) {
  result := &bytes.Buffer{}
  LoadAndPrint(&missingLoader{}, 1, result)
  assert.Contains(t, result.String(), "no such pet")
}

func TestLoadAndPrint_error(t *testing.T) {
  result := &bytes.Buffer{}
  LoadAndPrint(&errorLoader{}, 1, result)
  assert.Contains(t, result.String(), "failed to load")
}

func LoadAndPrint(loader Loader, ID int, dest io.Writer) {
  loadedPet, err := loader.Load(ID)
  if err != nil {
    fmt.Fprintf(dest, "failed to load pet with ID %d. err: %s", ID, err)
    return
  }

  if loadedPet == nil {
    fmt.Fprintf(dest, "no such pet found")
    return
  }

  fmt.Fprintf(dest, "Pet named %s loaded", loadedPet.Name)
}

如您所见,此代码期望依赖项以某种方式运行。虽然测试没有从依赖项强制执行此行为,但它们确实用于定义此代码的需求。

单元测试有助于恢复信心和增进理解:您的系统中是否有您不敢更改的代码,因为如果您这样做,某些东西会崩溃?如果你真的不确定它是做什么的代码呢?单元测试在这两种情况下都非常好。针对这段代码编写测试是一种不引人注目的方式,既可以了解它的功能,也可以验证它是否符合您认为的功能。这些测试还有一个额外的好处,那就是它们还可以作为任何未来更改的回归预防,并教会其他人此代码的功能。

那么为什么我要编写单元测试呢?

对我来说,编写单元测试最令人信服的原因是它让我感觉良好。在一天或一周结束后回到家里,知道一切都按预期进行,并且测试正在确保这一点,感觉很棒。

这并不是说没有 bug,但肯定更少。一旦修复,虫子就不会回来了,让我免于尴尬,也节省了我的时间。而且,也许最重要的是,修复 bug 意味着晚上和周末更少的支持电话,因为有些东西坏了。

我应该测试什么?

我希望我有一个清晰的、可量化的指标来告诉你应该测试什么,不应该测试什么,但它并没有那么清晰。第一条规则明确如下:

Don't test co**de that is too simple break.

这包括语言功能,如以下代码所示:

func NewPet(name string) *Pet {
   return &Pet{
      Name: name,
   }
}

func TestLanguageFeatures(t *testing.T) {
   petFish := NewPet("Goldie")
   assert.IsType(t, &Pet{}, petFish)
}

这还包括简单的函数,如以下代码所示:

func concat(a, b string) string {
   return a + b
}

func TestTooSimple(t *testing.T) {
   a := "Hello "
   b := "World"
   expected := "Hello World"

   assert.Equal(t, expected, concat(a, b))
}

在那之后,要务实。我们通过编写有效的代码获得报酬;测试只是一种工具,用于确保它确实如此,并继续这样做。测试太多是完全可能的。过多的测试不仅会导致大量额外的工作,还会导致测试变得脆弱,并且在重构或扩展期间经常中断。

因此,我建议从稍微高一点的黑盒级别进行测试。看看本例中的结构:

type PetSaver struct{}

// save the supplied pet and return the ID
func (p PetSaver) Save(pet Pet) (int, error) {
   err := p.validate(pet)
   if err != nil {
      return 0, err
   }

   result, err := p.save(pet)
   if err != nil {
      return 0, err
   }

   return p.extractID(result)
}

// ensure the pet record is complete
func (p PetSaver) validate(pet Pet) (error) {
   return nil
}

// save to the datastore
func (p PetSaver) save(pet Pet) (sql.Result, error) {
   return nil, nil
}

// extract the ID from the result
func (p PetSaver) extractID(result sql.Result) (int, error) {
   return 0, nil
}

如果我们要为这个结构的每个方法编写测试,那么我们首先会被阻止重构这些方法,甚至从Save() 中提取它们,因为我们也必须重构相应的测试。然而,如果我们只测试Save()方法,这是其他人使用的唯一方法,那么我们可以重构其余的方法,而麻烦要少得多。

测试的类型也很重要。通常,我们应该测试以下各项:

  • 快乐之路:这是一切按预期进行的时候。这些测试也倾向于记录如何使用代码。
  • 输入错误:不正确和意外的输入通常会导致代码以奇怪的方式运行。这些测试确保我们的代码以可预测的方式处理这些问题。
  • 依赖关系问题:另一个常见的失败原因是依赖关系未能按我们需要的方式执行,可能是由于编码错误(如回归)或环境问题(如丢失文件或调用数据库失败)。

希望到现在为止,您已经接受了单元测试,并且对它们能为您做的事情感到兴奋。测试中另一个经常被忽视的方面是测试的质量。这里,我不是在谈论用例覆盖率或代码覆盖率,而是原始代码质量。遗憾的是,以我们不允许自己为生产代码编写的方式编写测试是司空见惯的。

重复、可读性差和缺乏结构都是常见的错误。谢天谢地,这些问题很容易解决。第一步只是注意问题,并应用与生产代码相同的工作和技能水平。第二个要求突破一些特定于测试的技术;有很多,但在本章中,我只介绍三个。详情如下:

  • 表驱动测试
  • 存根
  • 嘲弄

表驱动测试

通常,在编写测试时,您会发现同一方法的多个测试会导致大量重复。举个例子:

func TestRound_down(t *testing.T) {
   in := float64(1.1)
   expected := 1

   result := Round(in)
   assert.Equal(t, expected, result)
}

func TestRound_up(t *testing.T) {
   in := float64(3.7)
   expected := 4

   result := Round(in)
   assert.Equal(t, expected, result)
}

func TestRound_noChange(t *testing.T) {
   in := float64(6.0)
   expected := 6

   result := Round(in)
   assert.Equal(t, expected, result)
}

这里的意图并不令人惊讶,也没有错。表驱动测试确认需要复制,并将变化提取到中。正是这个表驱动了代码的单个副本,否则这些副本就会被复制。让我们将中的测试转换为表驱动测试:

func TestRound(t *testing.T) {
   scenarios := []struct {
      desc     string
      in       float64
      expected int
   }{
      {
         desc:     "round down",
         in:       1.1,
         expected: 1,
      },
      {
         desc:     "round up",
         in:       3.7,
         expected: 4,
      },
      {
         desc:     "unchanged",
         in:       6.0,
         expected: 6,
      },
   }

   for _, scenario := range scenarios {
      in := float64(scenario.in)

      result := Round(in)
      assert.Equal(t, scenario.expected, result)
   }
}

我们的测试现在保证在该方法的所有场景中都是一致的,这反过来使它们更加有效。如果我们必须更改函数签名或调用模式,我们只有一个地方可以这样做,从而降低维护成本。最后,减少表中的输入和输出使得添加新的测试场景变得便宜,并通过鼓励我们关注输入来帮助识别测试场景。

存根

存根有时被称为测试双精度,它是依赖项(即接口)的伪实现,提供可预测的、通常是固定的结果。存根还用于帮助练习代码路径,例如错误,否则可能很难或不可能触发这些错误。

让我们看一个示例界面:

type PersonLoader interface {
   Load(ID int) (*Person, error)
}

假设 fetcher 接口的生产实现实际上调用了一个上游 REST 服务。使用我们之前的测试类型列表,我们想测试以下场景:

  • 快乐路径:取数器返回数据
  • **输入错误:**取数器找不到我们请求的Person
  • 系统错误:上游服务关闭

我们可以实现更多可能的测试,但这对于我们的目的来说已经足够了。

让我们考虑一下在不使用存根的情况下如何进行测试:

  • 快乐路径:上游服务必须启动并正常工作,我们必须确保始终有一个有效的 ID 进行请求。
  • 输入错误:上游服务必须正常运行,但在这种情况下,我们必须有一个保证无效的 ID;否则,这个测试将是不可靠的。
  • 系统错误:服务必须关闭?如果我们假设上游服务属于另一个团队或者有我们以外的用户,我认为他们不会希望我们每次需要测试时都关闭服务。我们可以为服务配置不正确的 URL,但是我们会为不同的测试场景运行不同的配置。

前面的场景中有很多非编程问题。让我们看看一点代码是否可以解决这个问题:

// Stubbed implementation of PersonLoader
type PersonLoaderStub struct {
   Person *Person
   Error error
}

func (p *PersonLoaderStub) Load(ID int) (*Person, error) {
   return p.Person, p.Error
}

通过前面的存根实现,我们现在可以使用表驱动测试为每个场景创建一个存根实例,如下代码所示:

func TestLoadPersonName(t *testing.T) {
   // this value does not matter as the stub ignores it
   fakeID := 1

   scenarios := []struct {
      desc         string
      loaderStub   *PersonLoaderStub
      expectedName string
      expectErr    bool
   }{
      {
         desc: "happy path",
         loaderStub: &PersonLoaderStub{
            Person: &Person{Name: "Sophia"},
         },
         expectedName: "Sophia",
         expectErr:    false,
      },
      {
         desc: "input error",
         loaderStub: &PersonLoaderStub{
            Error: ErrNotFound,
         },
         expectedName: "",
         expectErr:    true,
      },
      {
         desc: "system error path",
         loaderStub: &PersonLoaderStub{
            Error: errors.New("something failed"),
         },
         expectedName: "",
         expectErr:    true,
      },
   }

   for _, scenario := range scenarios {
      result, resultErr := LoadPersonName(scenario.loaderStub, fakeID)

      assert.Equal(t, scenario.expectedName, result, scenario.desc)
      assert.Equal(t, scenario.expectErr, resultErr != nil, scenario.desc)
   }
}

正如您所看到的,我们的测试现在不会因为依赖性而失败;它们不再需要项目本身之外的任何东西,甚至可能运行得更快。如果你觉得写存根很麻烦,我建议你做两件事。首先,在 ISP 上查看前面的第 2 章Go 的实体设计原则、,看看是否可以将接口拆分成更小的部分。第二,看看围棋社区中众多奇妙的工具之一;你一定会找到一个适合你需要的。

过度测试覆盖率

另一个可能出现的问题是测试覆盖率过高。是的,你读对了。编写太多的测试是可能的。程序员,作为一个有技术头脑的人,我们喜欢度量。单元测试覆盖率就是这样一个指标。虽然有可能实现 100%的测试覆盖率,但实现这一目标是一个巨大的时间消耗,结果代码可能相当糟糕。考虑下面的代码:

func WriteAndClose(destination io.WriteCloser, contents string) error {
   defer destination.Close()

   _, err := destination.Write([]byte(contents))
   if err != nil {
      return err
   }

   return nil 
}

为了实现 100%的覆盖率,我们必须在destination.Close()调用失败的地方编写一个测试。我们完全可以做到这一点,但它能实现什么?我们将测试什么?它将给我们另一个编写和维护的测试。如果这行代码不起作用,你会注意到吗?这个例子怎么样:

func PrintAsJSON(destination io.Writer, plant Plant) error {
   bytes, err := json.Marshal(plant)
   if err != nil {
      return err
   }

   destination.Write(bytes)
   return nil
}

type Plant struct {
   Name string
}

再一次,我们完全可以测试一下。但我们真的要测试吗?在本例中,我们将测试 Go 标准库中的 JSON 包是否能够正常工作。外部 SDK 和包应该有它们自己的测试,这样我们就可以相信它们会按照它们声称的那样做。如果不是这样,我们可以为它们编写测试并将它们发送回项目。这样,整个社区都会受益。

嘲弄

模拟非常类似于存根,但它们有一个根本的区别。嘲笑者有期望。当我们使用存根时,我们的测试并没有验证我们对依赖关系的使用;有了嘲弄,他们会的。您使用哪种测试在很大程度上取决于测试类型和依赖项本身。例如,您可能希望使用存根作为日志依赖项,除非您正在编写测试以确保代码在特定情况下记录日志。但是,对于数据库依赖项,通常需要一个 mock。让我们将以前的测试从存根更改为模拟,以确保进行这些调用:

func TestLoadPersonName(t *testing.T) {
   // this value does not matter as the stub ignores it
   fakeID := 1

   scenarios := []struct {
      desc          string
      configureMock func(stub *PersonLoaderMock)
      expectedName  string
      expectErr     bool
   }{
      {
         desc: "happy path",
         configureMock: func(loaderMock *PersonLoaderMock) {
            loaderMock.On("Load", mock.Anything).
               Return(&Person{Name: "Sophia"}, nil).
               Once()
         },
         expectedName: "Sophia",
         expectErr:    false,
      },
      {
         desc: "input error",
         configureMock: func(loaderMock *PersonLoaderMock) {
            loaderMock.On("Load", mock.Anything).
               Return(nil, ErrNotFound).
               Once()
         },
         expectedName: "",
         expectErr:    true,
      },
      {
         desc: "system error path",
         configureMock: func(loaderMock *PersonLoaderMock) {
            loaderMock.On("Load", mock.Anything).
               Return(nil, errors.New("something failed")).
               Once()
         },
         expectedName: "",
         expectErr:    true,
      },
   }

   for _, scenario := range scenarios {
      mockLoader := &PersonLoaderMock{}
      scenario.configureMock(mockLoader)

      result, resultErr := LoadPersonName(mockLoader, fakeID)

      assert.Equal(t, scenario.expectedName, result, scenario.desc)
      assert.Equal(t, scenario.expectErr, resultErr != nil, scenario.desc)
      assert.True(t, mockLoader.AssertExpectations(t), scenario.desc)
   }
}

在前面的示例中,我们正在验证是否进行了适当的调用,并且输入是否如我们所期望的那样。考虑到基于模拟的测试更为明确,它们通常比基于存根的等价测试更为脆弱和冗长。我能给你的最好的建议是选择最适合你尝试写的选项,如果设置的数量似乎过多,考虑一下这意味着你正在测试的代码。您可能会遇到功能嫉妒或抽象效率低下的问题。对 DIP 或 SRP 进行重构可能会有所帮助。

就像存根一样,社区中也有许多用于生成模拟的伟大工具。我个人使用过嘲弄(https://github.com/vektra/mockery 维克特拉的

您可以使用以下命令安装 Mockry:

$ go get github.com/vektra/mockery/.../

安装后,我们可以使用命令行中的 mockry,或者使用 Go SDK 提供的go generate工具,通过向源代码中添加注释,为我们的测试接口生成 mock,如下代码所示:

//go:generate mockery -name PersonLoader -testonly -inpkg -case=underscore
type PersonLoader interface {
   Load(ID int) (*Person, error)
}

完成后,我们将运行以下操作:

$ go generate ./

然后,可以像我们在前面的示例中所做的那样使用生成的模拟。我们将在本书的第二部分使用 mocky 和它产生的大量 mocks。如果您希望下载 Mockry,您将在本章末尾找到他们的 GitHub 项目的链接。

试验诱导损伤

在 2014 年的一篇博文中,David Heinemeier Hansson表示,仅为了使测试更容易或更快而对系统进行的更改会导致测试导致的损坏。虽然我同意大卫的意图,但我不确定我们是否同意细节。他创造这个术语是为了回应他认为过度的应用程序 DI 和测试驱动开发TDD)。

就我个人而言,我对两者都采取务实的态度。它们是工具。请试一试。如果他们为你工作,那太棒了。如果没有,也没关系。我从来没有能够让 TDD 像其他方法一样对我有效率。一般来说,我会编写我的函数,至少是 happy path,然后应用我的测试。然后我重构并清理。

试验引起损坏的警告标志

虽然测试可能会以多种方式损坏软件设计,但以下是一些更常见的损坏。

仅因测试而存在的参数、配置选项或输出

虽然这方面的单个实例可能感觉不到它有巨大的影响,但成本最终还是会增加。请记住,每个参数、选项和输出都是用户必须理解的。同样,每个参数、选项和输出都必须进行测试、记录和维护。

导致或由泄漏抽象引起的参数

常见的情况是,将数据库连接字符串或 URL 传递到业务逻辑层的唯一目的是将其传递到数据层(数据库或 HTTP 客户端)。通常,动机是通过层传递配置,这样我们就可以将实时配置替换为更友好的测试。这听起来不错,但它破坏了数据层的封装。也许更令人担忧的是,如果我们将数据层实现更改为其他实现,我们可能会进行大规模的鸟枪手术。这里的实际问题不是测试,而是我们如何选择交换数据层。使用 DIP,我们可以将需求定义为业务逻辑层中的接口,然后对其进行模拟或存根。这将使业务逻辑层与数据层完全解耦,并消除通过测试配置的需要。

在生产代码中发布模拟

模拟和存根是用于测试的工具;因此,它们应该只存在于测试代码中。在 Go 中,这意味着一个_test.go文件。我见过许多好心人在生产代码中发布接口及其模拟。第一个问题是,它引入了一种可能性,不管这种可能性多么遥远,这种代码最终会进入生产环境。根据这个错误在系统中的位置,结果可能是灾难性的。

第二个问题更为微妙。在发布界面和模拟时,目的是减少重复,这非常好。然而,这也增加了对变化的依赖性和阻力。一旦此代码被其他人发布和采用,修改它将需要更改它的所有用法。

使用 Godepgraph 可视化包依赖关系

在一本关于 DI 的书中,你可以期望我们花很多时间讨论依赖关系。最底层的依赖关系、函数、结构和接口很容易可视化;我们可以阅读代码,或者,如果我们想要一幅漂亮的图片,我们可以制作一个类图,如下所示:

如果我们缩小到包级别并尝试映射包之间的依赖关系,那么生活就会变得更加困难。这就是我们再次依赖开源社区丰富的开源工具的地方。这一次,我们需要两个名为godepgraphGraphviz的工具 http://www.graphviz.org/ )。Godepgraph 是一个生成 Go 包依赖关系图的程序,Graphviz 是一个源图形可视化软件。

安装工具

一个简单的go get将安装godepgraph,如下代码所示:

 $ go get github.com/kisielk/godepgraph

如何安装 Graphviz 取决于您的操作系统。您可以使用 Windows 二进制文件、Linux 软件包以及 MacPorts 和 HomeBrew for OSX。

生成依赖关系图

安装完所有内容后,将执行以下命令:

$ godepgraph github.com/kisielk/godepgraph | dot -Tpng -o godepgraph.png

将为您制作以下精美图片:

正如您所看到的,godepgraph的依赖关系图是漂亮而平坦的,并且只依赖于标准库中的包(绿色圆圈)。

让我们尝试一些更复杂的方法:让我们为本书第二部分将要使用的代码生成依赖关系图:

$ godepgraph github.com/PacktPublishing/Hands-On-Dependency-Injection-in-Go/ch04/acme/ | dot -Tpng -o acme-graph-v1.png

这给了我们一个难以置信的复杂图形,它永远不会出现在页面上。如果你想知道它有多复杂,请看一下ch03/04_visualizing_dependencies/acme-graph-v1.png。不要太担心试图弄清楚细节;它现在不是一个超级有用的形式。

要解决这个问题,我们可以做的第一件事是删除标准库导入(具有-s标志),如下面的代码所示。我们可以假设使用标准库是可以接受的,并且我们不需要将其转换为抽象或在以下方面使用 DI:

$ godepgraph -s github.com/PacktPublishing/Hands-On-Dependency-Injection-in-Go/ch04/acme/ | dot -Tpng -o acme-graph-v2.png

我们可以用这个图表,但对我来说还是太复杂了。假设我们不鲁莽地采用外部依赖,我们可以像对待标准库一样对待它们,并将它们隐藏在图形中(使用-o标志),如下代码所示:

$ godepgraph -s -o github.com/PacktPublishing/Hands-On-Dependency-Injection-in-Go/ch04/acme/ github.com/PacktPublishing/Hands-On-Dependency-Injection-in-Go/ch04/acme/ | dot -Tpng -o acme-graph-v3.png

这给了我们以下信息:

移除所有外部包后,我们可以看到我们的包是如何相互关联和依赖的。

如果您使用的是 OSX 或 Linux,我已经在本章的源代码中包含了一个名为depgraph.sh的 Bash 脚本,用于生成这些图。

解释依赖关系图

就像编程世界中的许多事情一样,依赖关系图所说的内容也非常容易解释。我使用图表来发现潜在的问题,然后在代码中搜索这些问题。

那么,一个完美图是什么样的呢?如果有一个,它将是非常平坦的,几乎所有的东西都挂在主包装上。在这样一个系统中,所有包都将彼此完全解耦,并且除了它们的外部依赖项和标准库之外没有任何依赖项。

这实在不可行。正如您将在本书第二部分中看到的各种 DI 方法一样,我们的目标通常是解耦各层,以便依赖关系从上到下只在一个方向上流动。

从抽象的角度来看,这类似于以下内容:

考虑到这一点,我们在图表中看到了哪些潜在问题?

当观察任何一个包裹时,首先要考虑的是有多少箭指向或射出。这是耦合的基本度量。指向包的每个箭头都表示此包的用户。因此,每个指向内部的箭头都意味着,如果我们对当前包进行更改,那么包可能必须更改。反过来说,当前方案所依赖的方案越多,就越有可能因此而改变。

考虑到 DIP,虽然采用另一个包中的接口是一件快速而简单的事情,但定义自己的接口允许我们依靠自己,并减少更改的可能性。

接下来跳出的是 config 包。几乎每个包裹都依赖它。正如我们所看到的,有了这么多的责任,对该包进行更改可能会很棘手。就诡计而言,不远落后于此的是日志包。也许最令人担心的是配置包依赖于日志记录包。这意味着我们离循环依赖性问题只有一步之遥。在后面的章节中,我们需要利用 DI 来处理这两个问题。

否则,图形就相当不错了;它像金字塔一样从主包中流出,并且几乎所有依赖项都在一个方向上。下一次,当您正在寻找改进代码库的方法或遇到循环依赖性问题时,为什么不启动godepgraph,看看它对您的系统有何影响。依赖关系图不会确切地告诉您哪里有问题或哪里没有问题,但它会提示您从哪里开始查找。

总结

祝贺我们到达了第一节的末尾!希望在这一点上,您已经发现了一些新的东西,或者可能已经想起了一些您已经忘记的软件设计概念。

编程,就像任何专业的努力一样,值得不断的讨论、学习和健康的怀疑。

在第二部分中,您将发现几个非常不同的 DI 技术,有些您可能喜欢,有些您可能不喜欢。有了到目前为止我们已经检查过的所有内容,您将毫不费力地确定每种技术如何以及何时适用于您。

问题

  1. 为什么代码的可用性很重要?
  2. 谁从具有出色用户体验的代码中获益最多?
  3. 你如何构建一个好的用户体验?
  4. 单元测试能为您做什么?
  5. 你应该考虑什么样的测试场景?
  6. 表驱动测试有什么帮助?
  7. 测试如何损害您的软件设计?*