Това е втората статия от поредица за вълнуващото поле на софтуерното тестване от гледна точка на разработчиците. В случай, че сте пропуснали първото, вижте „Защо пишем тестове за нашия код“, а в случай че сте пропуснали последното вижте „Как да напишем тестови случаи за нашия код“. Пътят към Nirvana е да се увеличи максимално възможността за тестване на кода, тъй като това от своя страна води до код, който може да се поддържа, да не говорим за лекота при внедряване в производствена среда.

Дефиниции

Подробности за внедряването — Помислете за математическата функция f.

Y = f(X)

При даден X (неговия вход) той произвежда Y (своя изход). Как се извежда Y е детайл от изпълнението.

Рефакторинг на кода — Промяна на подробностите за изпълнението, без да се засяга изхода. Като потребител на „кода“ няма разлика преди и след рефакторинг.

Крехки тестове и устойчивост на рефакторинг

Ако всеки път, когато правите промени в кода си, трябва да се справяте с каскада от неуспешни тестове, тогава ще започнете да развивате съпротива срещу желанието да правите промени изобщо. Неизбежно е да се налага да актуализирате тестове след промяна на кода, но тестове, които се провалят след промени в детайлите на изпълнението (като част от рефакторинг) е определението за крехки тестове. Обратно, тестовете, които не се провалят след такива промени, са устойчиви на рефакторинг, което е ключът както към благосъстоянието на разработчиците, така и към минимизиране на работата.

Следователно, за да избегнете крехки тестове във вашия пакет от тестове, стремете се да пишете тестове, които проверяват резултата от функциите и състоянието на обектите, вместо КАК са постигнати. Повечето езици за програмиране имат функции, които улесняват това. Например в Go има концепцията за експортирани и неочаквани функции/типове и т.н. Всичко, което не е експортирано, е детайл на изпълнението и следователно не трябва да се тества, за да се избегне чупливост. В езици като Java и C# има ключови думи като публичен, частен и т.н., които по същия начин дават на разработчиците инструментите за капсулиране и скриване на подробности за изпълнението.

Тестваемостта на кода като двигател за разделяне на проблемите

Често изходният код на приложение е разделен на логически слоеве като услугата за приложение, домейн, инфраструктура и т.н. Един от двигателите за това разделяне е възможността за тестване на кода. В предишния раздел беше направен изводът, че за да има тестов пакет, устойчив на рефакторинг, той трябва да има възможно най-малко крехки тестове. За да се постигне това, може да се използва концепцията „разделяне на загрижеността“.

Без никакво наслояване или каквото и да е разделяне, кодовата база е почти купчина „скриптове за транзакции“. Ако си представим всеки скрипт като голяма функция, която може да съдържа всякакъв вид операции като извикване на външни услуги/компоненти, извършване на изчисления, трансформиране на структури от данни от една форма в друга и т.н., тогава писането на некрехки тестове ще бъде близо до невъзможно, тъй като писането на тестове, които просто предоставят вход на функция и проверява нейния изход, работи само когато имате работа с чисти функции. Веднага щом се направят извиквания към неща, които не са част от вашата кодова база, тогава имате нужда от „тестови двойници“, което изисква тестове, които са запознати с детайлите на изпълнението.

Следният пример на код показва скрипт за транзакция, написан в комбинация от Go- и псевдокод. Как бихте тествали тази функция?

// NOTE! Error handling is omitted in this example
func (srv *HttpServer) sendEmail(w http.ResponseWriter, r *http.Request) {
  var reqModel requestModel
  json.Unmarshal(r.Bytes(), &reqModel)
  
  validate.Email(reqModel.From)
  validate.Email(reqModel.To)
  validate.UUID(reqModel.SenderID)
 
  ctx := r.Context()
  accountClient, closeAccountClient := accountclient.New()
  defer closeAccountClient()
  if _, err := accountClient.FindByEmail(ctx, reqModel.From); err != nil {
    // account does not exist so we don't send this email
  }

  conn := db.NewConn(ctx, "User ID=postgres;Password=xYYx;Server=localhost;Port=5432;Database=email;")
  defer conn.Close()
  query := fmt.Sprintf("SELECT id, name, emails_sent FROM sender WHERE id = '%s'", reqModel.SenderID)
  row := conn.QueryRow(ctx, query)
  // * reading from row into object sender omitted *

  // Business logic which in this case checks constraints
  // and increments the EmailsSent field.
  if sender.EmailsSent > 99 {
    w.WriteHeader(http.StatusPreconditionFailed)
    w.Write(nil)
    return
  }
  sender.IncrementEmailsSent()
  
  command := fmt.Sprintf("UPDATE sender SET emails_sent = %d WHERE id = '%s'", sender.EmailsSent, sender.ID)
  conn.Exec(ctx, command)

  // write to audit log, who sent an email and when
  command := fmt.Sprintf(
    "INSERT INTO auditlog (id, sender_id, from, to, sent_at) VALUES('%s', '%s', '%s', '%s', '%s'",
    uuid.NewString(), reqModel.SenderID, reqModel.From, reqModel.To, time.Now().UTC(),
  )
  conn.Exec(ctx, command)

  // notify other µservices that an email was sent
  pubsubClient := googlecloudplatform.NewPubSubClient()
  pubsubClient.Publish(ctx, emailSent{
    From: reqModel.From,
    To: reqModel.To,
    SenderID: reqModel.SenderID,
    EventID: uuid.NewString(),
    PublishedAt: time.Now().UTC(),
  })

  w.WriteHeader(http.StatusOK)
  w.Write(nil)
}

Тестовете от край до край могат да се използват за тестване на горния HTTP манипулатор, но това не е нещо, което може да се стартира лесно и да предостави бърза обратна връзка на програмиста. За постигане на бърза обратна връзка са необходими единични тестове, но това не е възможно, тъй като при извикване на външни компоненти функцията по-горе използва клиенти на реални библиотеки, които инстанцира сама. Една добра първа стъпка, за да го направите годен за тестване на единици, би било да се инжектират всички зависимости и да се използват абстракции (интерфейси в този пример), за разлика от действителните реализации. След извършване на тези промени кодът сега изглежда както е показано по-долу.

// HttpServer dependencies abstracted into interfaces
type (
  accountFinder interface {
    FindByEmail(context.Context, requestModel)
  }
  
  dbConn interface {
    Exec(context.Context, string)
    QueryRow(context.Context, string) Row
  }

  pubsub interface {
    Publish(context.Context, emailSent)
  }
)

type HttpServer struct {
  accountFinder accountFinder
  dbConn dbConn
  pubsub pubsub
}

// NOTE! Error handling is omitted in this example
func (srv *HttpServer) sendEmail(w http.ResponseWriter, r *http.Request) {
  var reqModel requestModel
  json.Unmarshal(r.Bytes(), &reqModel)
  
  validate.Email(reqModel.From)
  validate.Email(reqModel.To)
  validate.UUID(reqModel.SenderID)
 
  ctx := r.Context()
  if _, err := srv.accountFinder.FindByEmail(ctx, reqModel.From); err != nil {
    // account does not exist so we don't send this email
  }

  query := fmt.Sprintf("SELECT id, name, emails_sent FROM sender WHERE id = '%s'", reqModel.SenderID)
  row := srv.dbConn.QueryRow(ctx, query)
  // * reading from row into object sender omitted *

  // Business logic which in this case checks constraints
  // and increments the EmailsSent field.
  if sender.EmailsSent > 99 {
    w.WriteHeader(http.StatusPreconditionFailed)
    w.Write(nil)
    return
  }
  sender.EmailsSent++
  
  command := fmt.Sprintf("UPDATE sender SET emails_sent = %d WHERE id = '%s'", sender.EmailsSent, sender.ID)
  srv.dbConn.Exec(ctx, command)

  // write to audit log, who sent an email and when
  command := fmt.Sprintf(
    "INSERT INTO auditlog (id, sender_id, from, to, sent_at) VALUES('%s', '%s', '%s', '%s', '%s'",
    uuid.NewString(), reqModel.SenderID, reqModel.From, reqModel.To, time.Now().UTC(),
  )
  srv.dbConn.Exec(ctx, command)

  // notify other µservices that an email was sent
  srv.pubsub.Publish(ctx, emailSent{
    From: reqModel.From,
    To: reqModel.To,
    SenderID: reqModel.SenderID,
    EventID: uuid.NewString(),
    PublishedAt: time.Now().UTC(),
  })

  w.WriteHeader(http.StatusOK)
  w.Write(nil)
}

Примерът по-горе вече може да се тества с единици, но за да направите тестовете възможно най-нечупливи, трябва да отделите частите на кода, които могат да бъдат направени чисти, от частите, които не могат. От какъв код можем да извлечем и направим чисти функции?

Обикновено бизнес логиката може да бъде изразена в чисти функции и същото важи и за горния пример. Останалите операции са или комуникация с някакъв външен компонент, като база данни или Pub/Sub доставчик, или валидиране на модела на заявката. Кодът за валидиране в този пример вече е извлечен в чисти функции (за всеки вход е възможно да се наблюдава резултатът (вярно или невярно) и вече трябва да има обширни единични тестове, които ги покриват.

В следващата итерация на примера обектът изпращач е преместен в различен пакет, а именно пакета на домейна.

// HttpServer dependencies abstracted into interfaces
type (
  accountFinder interface {
    FindByEmail(context.Context, requestModel)
  }
  
  dbConn interface {
    Exec(context.Context, string)
    QueryRow(context.Context, string) Row
  }

  pubsub interface {
    Publish(context.Context, emailSent)
  }
)

type HttpServer struct {
  accountFinder accountFinder
  dbConn dbConn
  pubsub pubsub
}

// NOTE! Error handling is omitted in this example
func (srv *HttpServer) sendEmail(w http.ResponseWriter, r *http.Request) {
  var reqModel requestModel
  json.Unmarshal(r.Bytes(), &reqModel)
  
  validate.Email(reqModel.From)
  validate.Email(reqModel.To)
  validate.UUID(reqModel.SenderID)
 
  ctx := r.Context()
  if _, err := srv.accountFinder.FindByEmail(ctx, reqModel.From); err != nil {
    // account does not exist so we don't send this email
  }

  query := fmt.Sprintf("SELECT id, name, emails_sent FROM sender WHERE id = '%s'", reqModel.SenderID)
  row := srv.dbConn.QueryRow(ctx, query)
  senderID, name, emailsSent := senderFieldsFrom(row)

  // Business logic which in this case checks constraints
  // and increments the EmailsSent field.
  sender := domain.NewSender(senderID, name, emailsSent)

  if !sender.CanSendEmail {
    w.WriteHeader(http.StatusPreconditionFailed)
    w.Write(nil)
    return
  }
  sender.IncrementEmailsSent()
  
  command := fmt.Sprintf("UPDATE sender SET emails_sent = %d WHERE id = '%s'", sender.EmailsSent, sender.ID)
  srv.dbConn.Exec(ctx, command)

  // write to audit log, who sent an email and when
  command := fmt.Sprintf(
    "INSERT INTO auditlog (id, sender_id, from, to, sent_at) VALUES('%s', '%s', '%s', '%s', '%s'",
    uuid.NewString(), reqModel.SenderID, reqModel.From, reqModel.To, time.Now().UTC(),
  )
  srv.dbConn.Exec(ctx, command)

  // notify other µservices that an email was sent
  srv.pubsub.Publish(ctx, emailSent{
    From: reqModel.From,
    To: reqModel.To,
    SenderID: reqModel.SenderID,
    EventID: uuid.NewString(),
    PublishedAt: time.Now().UTC(),
  })

  w.WriteHeader(http.StatusOK)
  w.Write(nil)
}

-----
package domain

type Sender struct {
  id string
  name string
  emailsSent int
}

func (s *Sender) CanSendEmail() bool {
  if s.emailsSent > 99 {
    return false
  }
  return true
}

func (s *Sender) IncrementEmailsSent() {
  s.emailsSent++
}

func (s *Sender) EmailsSent() int {
  return s.emailsSent
}

Сега методите CanSendEmail() и IncrementEmailsSent() са чисти и следователно нечупливи. Вече е възможно да се създават обширни модулни тестове за тях (независимо от тестовете за метода на манипулатора на HTTP sendEmail()), които ще осигурят бърза обратна връзка и ще бъдат устойчиви на рефакторинг.

Беше споменато преди, но си струва да го повторим отново, частите, които обикновено могат да бъдат направени чисти, са частите, които поставяте във вашия домейн слой, известен също като вашия бизнес логичен, моделен или основен слой. Това са изчисления и поведение, променящо състоянието, ограничено до вашите бизнес обекти в паметта.

След като приложите принципа за разделяне на притесненията върху кода, в крайна сметка получавате два слоя. Слоят за оркестрация (известен също като слой на услугата за приложения) и слой на домейна. Първият все още ще изглежда като скрипт, последователност от операции...

  1. Обадете се на този µservice
  2. След това изтеглете това от базата данни
  3. След това извикайте някакво поведение на обекти на домейн
  4. След това публикувайте някакво събитие
  5. И накрая съхранете резултата в базата данни

Тестовете за кода на оркестрационния слой не могат да бъдат направени нечупливи, тъй като всички те са свързани с проверка на подробностите за внедряването. Тестовете за горната последователност биха проверили например дали µservice в (1) е извикана и че е извикана с очакваните аргументи. Тестовете също ще проверят дали извличането на данни в (2) е извикано и т.н. Неговият резултат обаче ще трябва да бъде осигурен чрез използване на тестови дубли.

Повече за тестването на „слой за оркестриране“ – код ще последва в следващата статия от тази серия.