Заключительная часть статей, посвященных тому, как можно использовать принципы чистого кода в TypeScript(ps. Все эти принципы относятся не только к языку TypeScript).
Тестирование
Тестирование важнее деплоя. Если у вас нет тестов или их мало, то каждый раз при выкладке кода на боевые сервера у вас не будет уверенности, что ничего не сломается. Решение о достаточном количестве тестов остается на совести вашей команды, но 100% покрытие тестами всех выражений и ветвлений обеспечивает высокое доверие к вашему коду и спокойствие всех разработчиков. Из этого следует, что в дополнение к отличному фреймворку для тестирования, необходимо также использовать хороший инструмент покрытия.
Нет никакого оправдания, чтобы не писать тесты. Есть много хороших фреймворков для тестирования на JS с поддержкой типов для TypeScript, так что вы найдите тот который понравится вашей команде. Когда вы найдете тот, который работает для вашей команды, тогда стремитесь всегда писать тесты для каждой новой фичи/модуля, которую вы пишете. Если вы предпочитаете метод тест-ориентированной разработки (TDD), это замечательно, но главное — просто убедиться, что вы достигли своих целей покрытия, прежде чем запускать какую-либо функцию или реорганизовать существующую.
Три закона TDD
- Новый рабочий код пишется только после того, как будет написан модульный тест, который не проходит.
- Вы пишете ровно такой объем кода модульного теста, какой необходим для того, чтобы этот тест не проходил (если код теста не компилируется, считается, что он не проходит).
- Вы пишете ровно такой объем рабочего кода, какой необходим для прохождения модульного теста, который в данный момент не проходит.
Правила F.I.R.S.T.
Чистые тесты должны следовать правилам:
- Быстрота(Fast) Тесты должны выполняться быстро. Все мы знаем, что разработчики люди, а люди ленивы, поскольку эти выражения являются “транзитивными”, то можно сделать вывод, что люди тоже ленивы. А ленивый человек не захочет запускать тесты при каждом изменении кода, если они будут долго выполняться.
- Независимость(Independent) Тесты не должны зависеть друг от друга. Они должны обеспечивать одинаковые выходные данные независимо от того, выполняются ли они независимо или все вместе в любом порядке.
- Повторяемость(Repeatable) Тесты должны выполняться в любой среде, и не должно быть никаких оправданий тому, почему они провалились.
- Очевидность(Self-Validating) Тест должен отвечать либо Passed, либо Failed. Вам не нужно сравнивать файлы логов, для чтобы ответить, что тест пройден.
- Своевременность(Timely) Юнит тесты должны быть написаны перед производственным кодом. Если вы пишете тесты после производственного кода, то вам может показаться, что писать тесты слишком сложно.
Один кейс на тест
Тесты также должны соответствовать Принципу единой ответственности(SPP). Делайте только одно утверждение за единицу теста.(ps. не пренебрегайте этим правилом)
import { assert } from 'chai';
describe('AwesomeDate', () => {
it('handles date boundaries', () => {
let date: AwesomeDate;
date = new AwesomeDate('1/1/2015');
assert.equal('1/31/2015', date.addDays(30));
date = new AwesomeDate('2/1/2016');
assert.equal('2/29/2016', date.addDays(28));
date = new AwesomeDate('2/1/2015');
assert.equal('3/1/2015', date.addDays(28));
});
});
Хорошо:
import { assert } from 'chai';
describe('AwesomeDate', () => {
it('handles 30-day months', () => {
const date = new AwesomeDate('1/1/2015');
assert.equal('1/31/2015', date.addDays(30));
});
it('handles leap year', () => {
const date = new AwesomeDate('2/1/2016');
assert.equal('2/29/2016', date.addDays(28));
});
it('handles non-leap year', () => {
const date = new AwesomeDate('2/1/2015');
assert.equal('3/1/2015', date.addDays(28));
});
});
Асинхронность
Используйте promises а не callbacks
Callback-функции ухудшают читаемость и приводят к чрезмерному количеству вложенности (ад обратных вызовов(callback hell)). Существуют утилиты, которые преобразуют существующие функции, используя стиль callback-ов, в версию, которая возвращает промисы (для Node.js смотрите util.promisify
, для общего назначения смотрите pify, es6-promisify)
Плохо:
import { get } from 'request';
import { writeFile } from 'fs';
function downloadPage(url: string, saveTo: string, callback: (error: Error, content?: string) => void) {
get(url, (error, response) => {
if (error) {
callback(error);
} else {
writeFile(saveTo, response.body, (error) => {
if (error) {
callback(error);
} else {
callback(null, response.body);
}
});
}
});
}
downloadPage('https://en.wikipedia.org/wiki/Robert_Cecil_Martin', 'article.html', (error, content) => {
if (error) {
console.error(error);
} else {
console.log(content);
}
});
Хорошо:
import { get } from 'request';
import { writeFile } from 'fs';
import { promisify } from 'util';
const write = promisify(writeFile);
function downloadPage(url: string, saveTo: string): Promise<string> {
return get(url)
.then(response => write(saveTo, response));
}
downloadPage('https://en.wikipedia.org/wiki/Robert_Cecil_Martin', 'article.html')
.then(content => console.log(content))
.catch(error => console.error(error));
Промисы поддерживают несколько вспомогательных методов, которые помогают сделать код более понятным:
Методы | Описание |
---|---|
Promise.resolve(value) |
Преобразуйте значение в решенный промис. |
Promise.reject(error) |
Преобразуйте ошибку в отклоненный промис. |
Promise.all(promises) |
Возвращает новый промис, который выполняется с массивом значений выполнения для переданных промисов или отклоняется по причине первого промиса, который выполняется с ошибкой. |
Promise.race(promises) |
Возвращает новый промис, который выполнен/отклонен с результатом/ошибкой первого выполненного промиса из массива переданных промисов. |
Promise.all
особенно полезен, когда есть необходимость запускать задачи параллельно. Promise.race
облегчает реализацию таких вещей, как тайм-ауты для промисов.
Обработка ошибок
Бросать ошибки — хорошее решение! Это означает, что во время выполнения вы будете знать, если что-то пошло не так, вы сможете остановить выполнение вашего приложения убив процесс (в Node) в нужный момент и увидеть место ошибки с помощью стек трейса в консоли.
Всегда используйте ошибки для отклонений(reject)
JavaScript и TypeScript позволяют вам делать throw
любым объектом. Промис также может быть отклонен с любым объектом причины. Рекомендуется использовать синтаксис throw
с типом Error
. Это потому что ваша ошибка может быть поймана в более высоком уровне кода с синтаксисом catch
. Было бы очень странно поймать там строковое сообщение и сделать отладку более болезненной. По той же причине вы должны отклонять промисы с типами Error
.
Плохо:
function calculateTotal(items: Item[]): number {
throw 'Not implemented.';
}
function get(): Promise<Item[]> {
return Promise.reject('Not implemented.');
}
Хорошо:
function calculateTotal(items: Item[]): number {
throw new Error('Not implemented.');
}
function get(): Promise<Item[]> {
return Promise.reject(new Error('Not implemented.'));
}
// or equivalent to:
async function get(): Promise<Item[]> {
throw new Error('Not implemented.');
}
Преимущество использования типов Error
заключается в том, что они поддерживается синтаксисом try/catch/finally
и неявно всеми ошибками и имеют свойство stack
, которое является очень мощным для отладки. Есть и другие альтернативы: не использовать синтаксис throw
и вместо этого всегда возвращать пользовательские объекты ошибок. TypeScript делает это еще проще.
Рассмотрим следующий пример:
type Result<R> = { isError: false, value: R };
type Failure<E> = { isError: true, error: E };
type Failable<R, E> = Result<R> | Failure<E>;
function calculateTotal(items: Item[]): Failable<number, 'empty'> {
if (items.length === 0) {
return { isError: true, error: 'empty' };
}
// ...
return { isError: false, value: 42 };
}
Для подробного объяснения этой идеи обратитесь к оригинальному посту.
Не игнорируйте отловленные ошибки
Игнорирование пойманной ошибки не дает вам возможности исправить или каким-либо образом отреагировать на ее появление. Логирование ошибок в консоль (console.log
) не намного лучше, так как зачастую оно может потеряться в море консольных записей. Оборачивание куска кода в try/catch
означает, что вы предполагаете возможность появления ошибки и имеете на этот случай четкий план.
Плохо:
try {
functionThatMightThrow();
} catch (error) {
console.log(error);
}
// or even worse
try {
functionThatMightThrow();
} catch (error) {
// ignore error
}
Хорошо:
import { logger } from './logging'
try {
functionThatMightThrow();
} catch (error) {
logger.log(error);
}
Не игнорируйте ошибки, возникшие в промисах
Вы не должны игнорировать ошибки в промисах по той же причине, что и в try/catch
.
Плохо:
getUser()
.then((user: User) => {
return sendEmail(user.email, 'Welcome!');
})
.catch((error) => {
console.log(error);
});
Хорошо:
import { logger } from './logging'
getUser()
.then((user: User) => {
return sendEmail(user.email, 'Welcome!');
})
.catch((error) => {
logger.log(error);
});
// or using the async/await syntax:
try {
const user = await getUser();
await sendEmail(user.email, 'Welcome!');
} catch (error) {
logger.log(error);
}
Форматирование
Форматирование носит субъективный характер. Как и во многом собранном здесь, в вопросе форматирования нет жестких правил, которым вы обязаны следовать. Главное — НЕ СПОРИТЬ по поводу форматирования. Есть множество инструментов для автоматизации этого. Используйте один! Это трата времени и денег когда инженеры спорят о форматировании. Общее правило, которому стоит следовать соблюдайте правила форматирования принятые в команде
Для TypeScript есть мощный инструмент под названием TSLint. Это статический анализ инструмент, который может помочь вам значительно улучшить читаемость и поддерживаемость вашего кода. Но лучще используйте ESLint, так как TSLint больше не поддерживается.
Есть готовые к использованию конфигурации TSLint и ESLint, на которые вы можете ссылаться в своих проектах:
TSLint Config Standard — стандартный набор правил
TSLint Config Airbnb — правила от Airbnb
TSLint Clean Code — Правила TSLint которые вдохновлены Clean Code: A Handbook of Agile Software Craftsmanship
TSLint react — правила, связанные с React & JSX
TSLint + Prettier — правила линта для Prettier средство форматирования кода
ESLint rules for TSLint — ESLint правила для TypeScript
Immutable — правила отключения мутации в TypeScript
Обратитесь также к этому великому TypeScript StyleGuide and Coding Conventions источнику.
Используйте один вариант именования
Использование заглавных букв говорит вам о ваших переменных, функциях и др… Эти правила субъективны, поэтому ваша команда может выбирать все, что они хотят. Дело в том, что независимо от того, что вы все выберите, просто будьте последовательны.
Плохо:
const DAYS_IN_WEEK = 7;
const daysInMonth = 30;
const songs = ['Back In Black', 'Stairway to Heaven', 'Hey Jude'];
const Artists = ['ACDC', 'Led Zeppelin', 'The Beatles'];
function eraseDatabase() {}
function restore_database() {}
type animal = { /* ... */ }
type Container = { /* ... */ }
Хорошо:
const DAYS_IN_WEEK = 7;
const DAYS_IN_MONTH = 30;
const SONGS = ['Back In Black', 'Stairway to Heaven', 'Hey Jude'];
const ARTISTS = ['ACDC', 'Led Zeppelin', 'The Beatles'];
function eraseDatabase() {}
function restoreDatabase() {}
type Animal = { /* ... */ }
type Container = { /* ... */ }
Предпочитайте использовать PascalCase
для имен классов, интерфейсов, типов и пространств имен. Предпочитайте использовать camelCase
для переменных, функций и членов класса.
Организация импортов
С помощью простых и понятных операторов импорта вы можете быстро увидеть зависимости текущего кода.
Убедитесь, что вы используете следующие хорошие практики для операторов import
:
- Операторы импорта должны быть в алфавитном порядке и сгруппированы.
- Неиспользованный импорт должен быть удален.
- Именованные импорты должны быть в алфавитном порядке (т.е.
import {A, B, C} from 'foo';
) - Источники импорта должны быть в алфавитном порядке в группах, т.е.:
import * as foo from 'a'; import * as bar from 'b';
- Группы импорта обозначены пустыми строками.
- Группы должны соблюдать следующий порядок:
- Полифилы (т.е.
import 'reflect-metadata';
) - Модули сборки Node (т.е.
import fs from 'fs';
) - Внешние модули (т.е.
import { query } from 'itiriri';
) - Внутренние модули (т.е.
import { UserService } from 'src/services/userService';
) - Модули из родительского каталога (т.е.
import foo from '../foo'; import qux from '../../foo/qux';
) - Модули из того же или родственного каталога (т.е.
import bar from './bar'; import baz from './bar/baz';
)
- Полифилы (т.е.
Плохо:
import { TypeDefinition } from '../types/typeDefinition';
import { AttributeTypes } from '../model/attribute';
import { ApiCredentials, Adapters } from './common/api/authorization';
import fs from 'fs';
import { ConfigPlugin } from './plugins/config/configPlugin';
import { BindingScopeEnum, Container } from 'inversify';
import 'reflect-metadata';
Хорошо:
import 'reflect-metadata';
import fs from 'fs';
import { BindingScopeEnum, Container } from 'inversify';
import { AttributeTypes } from '../model/attribute';
import { TypeDefinition } from '../types/typeDefinition';
import { ApiCredentials, Adapters } from './common/api/authorization';
import { ConfigPlugin } from './plugins/config/configPlugin';
Используйте typescript алиасы
Создайте более симпатичный импорт, определив пути и свойства baseUrl в разделе compilerOptions в tsconfig.json
Это позволит избежать длинных относительных путей при импорте.
Плохо:
import { UserService } from '../../../services/UserService';
Хорошо:
import { UserService } from '@services/UserService';
// tsconfig.json
...
"compilerOptions": {
...
"baseUrl": "src",
"paths": {
"@services": ["services/*"]
}
...
}
...
P.S.
serf
Еще лучше если в линтере будет включено правило запрета относительных импортов, особенно которые идут вверх / где "..." в пути импорта.
«Убеждаться» не нужно, нужно это автоматизировать github.com/renke/import-sort.
Опять же нет упоминания о включении соответствующих правил линтера.
JustDont
Я до сих пор не понимаю, почему людям мешают относительные пути. Любая приличная IDE их рефакторит автоматически, если вам нужно разложить код по-другому.
Код, написанный с применением относительных импортов, сохраняет свою связность при перемещении в другое место. При применении алиасов придётся таскать с собой и алиасы, а уж если точка прикрепления перенесенного кода изменится — алиасы еще и нужно будет переписать. Ручками.
serf
Я один из тех кто не любит относительные импорты. Они увеличивают когнитивную нагрузку потому что не всегда сразу «быстрым взглядом» поймешь откуда именно идет импорт если вложенность приличная. Алиасы тоже не очень люблю, мне нравится испортировать относительно корня проекта (да импорты могут быть длинными в таком случае, но зато никакой магии и просчитывать эти .../… не нужно, все сразу понятно откуда берется.
zhaparoff
Алиас обычно это не конкретный файл, а глоб. Он меняется в 1 месте, и автоматом все импорты из алиаснутой папки указывают в новое место. Опять же, современный IDE типа VSCode вполне умеют работать с ними. А вот с относительными путями при рефакторинге асе гораздо интереснее. Вы переместили папку с 10 файлами, и теперь вам надо все импорты переписать, вместо того чтобы обновить один алиас.
Но и алиасы к сожалению не панацея. Некоторые сборщики (например Parcel) не умеют в алиасы, во всяком случае, в стандартные из tsconfig. И это может доставить хлопот.
JustDont
«Современный IDE типа VSCode» с этим справляется автоматически уже очень давно. Так же, как и при переносе одного файла.
VolCh
Обычно мешают не просто относительные, а идущие вверх — их сложно вычислять в уме. Плюс, идущие вверх часто означают нарушение SRP и(или) DI.