From 26f2f6a9628388c5410460041feaad96886ba215 Mon Sep 17 00:00:00 2001 From: Stanislav Zhuk Date: Sun, 2 Jul 2023 17:17:55 +0300 Subject: [PATCH] Add package source with tests (#2) --- README.md | 178 +++++++++++++ composer.json | 62 +++++ config/slack-notifier.php | 65 +++++ phpunit.xml.dist | 38 +++ src/Config.php | 39 +++ src/Exceptions/FormatterClassDoesNotExist.php | 13 + src/Exceptions/WebhookSendFail.php | 14 + src/Exceptions/WebhookUrlNotValid.php | 13 + src/Facades/SlackNotifier.php | 23 ++ src/Notifications/SendToSlack.php | 171 ++++++++++++ src/SlackNotifierFormatter.php | 243 ++++++++++++++++++ src/SlackNotifierServiceProvider.php | 32 +++ tests/ConfigTest.php | 20 ++ tests/Pest.php | 5 + tests/SlackNotifierTest.php | 119 +++++++++ tests/TestCase.php | 16 ++ 16 files changed, 1051 insertions(+) create mode 100644 composer.json create mode 100644 config/slack-notifier.php create mode 100644 phpunit.xml.dist create mode 100755 src/Config.php create mode 100644 src/Exceptions/FormatterClassDoesNotExist.php create mode 100644 src/Exceptions/WebhookSendFail.php create mode 100644 src/Exceptions/WebhookUrlNotValid.php create mode 100644 src/Facades/SlackNotifier.php create mode 100644 src/Notifications/SendToSlack.php create mode 100755 src/SlackNotifierFormatter.php create mode 100644 src/SlackNotifierServiceProvider.php create mode 100644 tests/ConfigTest.php create mode 100644 tests/Pest.php create mode 100644 tests/SlackNotifierTest.php create mode 100644 tests/TestCase.php diff --git a/README.md b/README.md index 80db011..48f8746 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,185 @@ +[![Stand With Ukraine](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/banner-direct-single.svg)](https://stand-with-ukraine.pp.ua) + # Laravel Slack Notifier +[![Latest Version on Packagist](https://img.shields.io/packagist/v/stasadev/laravel-slack-notifier.svg?style=flat-square)](https://packagist.org/packages/stasadev/laravel-slack-notifier) +[![GitHub Tests Action Status](https://img.shields.io/github/actions/workflow/status/stasadev/laravel-slack-notifier/run-tests.yml?branch=main&label=tests&style=flat-square)](https://github.com/stasadev/laravel-slack-notifier/actions?query=workflow%3Arun-tests+branch%3Amain) +[![GitHub Code Style Action Status](https://img.shields.io/github/actions/workflow/status/stasadev/laravel-slack-notifier/fix-php-code-style-issues.yml?branch=main&label=code%20style&style=flat-square)](https://github.com/stasadev/laravel-slack-notifier/actions?query=workflow%3A"Fix+PHP+code+style+issues"+branch%3Amain) +[![Total Downloads](https://img.shields.io/packagist/dt/stasadev/laravel-slack-notifier.svg?style=flat-square)](https://packagist.org/packages/stasadev/laravel-slack-notifier) + Send exceptions and dump variables to Slack. +```php +use Stasadev\SlackNotifier\Facades\SlackNotifier; + +SlackNotifier::send(new \RuntimeException('Test exception')); +SlackNotifier::send('Test message'); +``` + +## Installation + +Install the package via composer: + +```bash +composer require stasadev/laravel-slack-notifier +``` + +All env variables used by this package (only `LOG_SLACK_WEBHOOK_URL` is required): + +```dotenv +APP_NAME=Laravel +LOG_SLACK_WEBHOOK_URL=https://hooks.slack.com/services/ABC +LOG_SLACK_CHANNEL= +LOG_SLACK_EMOJI=:boom: +LOG_SLACK_CACHE_SECONDS=0 +CACHE_DRIVER=file +``` + +How to get a webhook URL [in the Slack API docs](https://api.slack.com/messaging/webhooks). + +To temporarily disable all logging, simply comment out `LOG_SLACK_WEBHOOK_URL` or set it to an empty string or `null`. + +Optionally publish the [config](./config/slack-notifier.php) file with: + +```bash +php artisan vendor:publish --tag="slack-notifier" +``` + +## Usage + +To send a message to Slack, simply call `SlackNotifier::send()`. + +## Report Exception + +```php +// In Laravel 8.x and later +// app/Exceptions/Handler.php +public function register(): void +{ + $this->reportable(function (Throwable $e) { + \Stasadev\SlackNotifier\Facades\SlackNotifier::send($e); + }); +} + +// In Laravel 5.7.x, 5.8.x, 6.x, 7.x +// app/Exceptions/Handler.php +public function report(Throwable $exception) +{ + if ($this->shouldReport($exception) { + \Stasadev\SlackNotifier\Facades\SlackNotifier::send($exception); + } + + parent::report($exception); +} +``` + +## Dump Variable + +```php +use Stasadev\SlackNotifier\Facades\SlackNotifier; + +$variable = 'message'; +// $variable = ['test' => 'array']; +// $variable = new stdClass(); + +SlackNotifier::send($variable); +``` + +## Using multiple webhooks + +Use an alternative webhook, by specify extra ones in the config file. + +```php +// config/slack-notifier.php + +'webhook_urls' => [ + 'default' => 'https://hooks.slack.com/services/ABC', + 'testing' => 'https://hooks.slack.com/services/DEF', +], +``` + +The webhook to be used can be chosen using the `to` function. + +```php +use Stasadev\SlackNotifier\Facades\SlackNotifier; + +SlackNotifier::to('testing')->send('Test message'); +``` + +### Using a custom webhooks + +The `to` function also supports custom webhook URLs. + +```php +use Stasadev\SlackNotifier\Facades\SlackNotifier; + +SlackNotifier::to('https://custom-url.com')->send('Test message'); +``` + +## Sending message to another channel + +You can send a message to a channel (use `LOG_SLACK_CHANNEL`) other than the default one for the webhook, by passing it to the `channel` function. + +```php +use Stasadev\SlackNotifier\Facades\SlackNotifier; + +SlackNotifier::channel('reminders')->send('Test message'); +``` + +## Slack bot customizing + +Use `username` (use `APP_NAME`) and `emoji` (use `LOG_SLACK_EMOJI`) to make your messages unique, or override them right before sending. + +```php +use Stasadev\SlackNotifier\Facades\SlackNotifier; + +SlackNotifier::username('My Laravel Bot')->emoji(':tada:')->send('Test message'); +``` + +### Formatting + +Extend the default `Stasadev\SlackNotifier\SlackNotifierFormatter::class` to format the messages however you like. Then simply replace the `formatter` key in the configuration file. + +```php +// config/slack-notifier.php + +'formatter' => App\Formatters\CustomSlackNotifierFormatter::class, +``` + +### Additional context in the message + +Include additional `context` in a Slack message (use `dont_flash` to exclude sensitive info from `context`). It will be added as an attachment. + +### Exception stack trace filtering + +Stack traces for exceptions in Laravel usually contain many lines, including framework files. Usually, you are only interested in tracking exception details in the application files. +You can filter it out with the `dont_trace` config option. + +### Caching the same exceptions + +Sometimes a large group of exceptions is thrown, and you don't want to log each of them because they are the same. + +Use `LOG_SLACK_CACHE_SECONDS` (uses Laravel `CACHE_DRIVER` under the hood) to suppress output for X seconds, or pass it to the `cacheSeconds` function. + +```php +use Stasadev\SlackNotifier\Facades\SlackNotifier; + +SlackNotifier::cacheSeconds(60)->send(new \RuntimeException('Test exception')); +``` + +## Testing + +```bash +composer test +``` + +## Credits + +Inspired by [spatie/laravel-slack-alerts](https://github.com/spatie/laravel-slack-alerts). + +- [Stanislav Zhuk](https://github.com/stasadev) +- [All Contributors](../../contributors) + ## License The MIT License (MIT). Please see [License File](LICENSE.md) for more information. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..d6bca54 --- /dev/null +++ b/composer.json @@ -0,0 +1,62 @@ +{ + "name": "stasadev/laravel-slack-notifier", + "description": "Send exceptions and dump variables to Slack", + "license": "MIT", + "type": "library", + "keywords": [ + "laravel", + "notifications", + "slack" + ], + "authors": [ + { + "name": "Stanislav Zhuk", + "email": "stanislav.zhuk.work@gmail.com", + "role": "Developer" + } + ], + "homepage": "https://github.com/stasadev/laravel-slack-notifier", + "require": { + "php": "^7.1.3 || ^8.0", + "ext-json": "*", + "laravel/slack-notification-channel": "^1.0 || ^2.0", + "monolog/monolog": "^1.12 || ^2.0 || ^3.0", + "symfony/polyfill-php80": "^1.20" + }, + "require-dev": { + "laravel/pint": "^1.0", + "orchestra/testbench": "^8.0", + "pestphp/pest-plugin-laravel": "^2.0" + }, + "minimum-stability": "dev", + "prefer-stable": true, + "autoload": { + "psr-4": { + "Stasadev\\SlackNotifier\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Stasadev\\SlackNotifier\\Tests\\": "tests/" + } + }, + "config": { + "allow-plugins": { + "pestphp/pest-plugin": true + }, + "sort-packages": true + }, + "extra": { + "laravel": { + "providers": [ + "Stasadev\\SlackNotifier\\SlackNotifierServiceProvider" + ] + } + }, + "scripts": { + "post-autoload-dump": "@php ./vendor/bin/testbench package:discover --ansi", + "format": "vendor/bin/pint", + "test": "vendor/bin/pest", + "test-coverage": "vendor/bin/pest --coverage" + } +} diff --git a/config/slack-notifier.php b/config/slack-notifier.php new file mode 100644 index 0000000..abe397b --- /dev/null +++ b/config/slack-notifier.php @@ -0,0 +1,65 @@ + [ + 'default' => env('LOG_SLACK_WEBHOOK_URL'), + ], + + /* + * Override the Slack channel to which the message will be sent. + */ + 'channel' => env('LOG_SLACK_CHANNEL'), + + /* + * The name of the Slack bot. + */ + 'username' => env('APP_NAME', 'Laravel Log'), + + /* + * The emoji used for the Slack bot. + */ + 'emoji' => env('LOG_SLACK_EMOJI', ':boom:'), + + /* + * An exception can be triggered several times in a row. + * We can use the Laravel cache to suppress the same exception for "x" seconds. + */ + 'cache_seconds' => env('LOG_SLACK_CACHE_SECONDS', 0), + + /* + * A formatter for Slack message. + */ + 'formatter' => Stasadev\SlackNotifier\SlackNotifierFormatter::class, + + /* + * Add context for the Slack message. Possible values: + * 'get', 'post', 'request', 'headers', 'files', 'cookie', 'session', 'server' + */ + 'context' => [ + 'get', + 'post', + 'cookie', + 'session', + ], + + /* + * The list of the values from context that are never flashed to Slack. + */ + 'dont_flash' => [ + 'current_password', + 'password', + 'password_confirmation', + ], + + /* + * Lines containing any of these strings will be excluded from exceptions. + */ + 'dont_trace' => [ + '/vendor/symfony/', + '/vendor/laravel/framework/', + '/vendor/barryvdh/laravel-debugbar/', + ], +]; diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..6b35503 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,38 @@ + + + + + tests + + + + + + + + + + + + + + + ./src + + + diff --git a/src/Config.php b/src/Config.php new file mode 100755 index 0000000..ca82063 --- /dev/null +++ b/src/Config.php @@ -0,0 +1,39 @@ +getMessage(), 1, $exception->getPrevious()); + } +} diff --git a/src/Exceptions/WebhookUrlNotValid.php b/src/Exceptions/WebhookUrlNotValid.php new file mode 100644 index 0000000..50b30b3 --- /dev/null +++ b/src/Exceptions/WebhookUrlNotValid.php @@ -0,0 +1,13 @@ +to('default') + ->channel(config('slack-notifier.channel', '')) + ->username(config('slack-notifier.username', 'Laravel Log')) + ->emoji(config('slack-notifier.emoji', ':boom:')) + ->cacheSeconds((int) config('slack-notifier.cache_seconds', 0)); + + $this->formatter = Config::getFormatter(); + } + + public function to(string $to): self + { + $this->to = Config::getWebhookUrl($to); + + return $this; + } + + public function channel(?string $channel): self + { + $this->channel = $channel; + + return $this; + } + + public function username(string $username): self + { + $this->username = $username; + + return $this; + } + + public function emoji(string $emoji): self + { + $this->emoji = $emoji; + + return $this; + } + + public function cacheSeconds(int $cacheSeconds): self + { + $this->cacheSeconds = $cacheSeconds; + + return $this; + } + + public function send($message): void + { + if ($message instanceof Throwable) { + $this->exception = $message; + } else { + $this->variable = $message; + } + + try { + if ($this->exception instanceof WebhookSendFail) { + return; + } + + NotificationFacade::route('slack', $this->to)->notify($this); + } catch (Throwable $e) { + throw WebhookSendFail::make($e); + } + } + + public function via($notifiable): array + { + return $this->cached() ? [] : ['slack']; + } + + public function toSlack($notifiable): SlackMessage + { + $slackMessage = $this->formatter->format($this); + + return $slackMessage->from($this->username, $this->emoji) + ->to($this->channel); + } + + public function getException(): ?Throwable + { + return $this->exception; + } + + public function getVariable() + { + return $this->variable; + } + + protected function cached(): bool + { + // don't cache unless there are exceptions + if (! $this->exception) { + return false; + } + + $seconds = $this->cacheSeconds; + + if ($seconds < 1) { + return false; + } + + $key = Str::kebab($this->username.' Slack Log Message') + .'-'.sha1($this->exception); + + if (cache()->get($key)) { + return true; + } + + cache()->set($key, true, $seconds); + + return false; + } +} diff --git a/src/SlackNotifierFormatter.php b/src/SlackNotifierFormatter.php new file mode 100755 index 0000000..353c2ec --- /dev/null +++ b/src/SlackNotifierFormatter.php @@ -0,0 +1,243 @@ +message = new SlackMessage(); + $this->normalizer = new NormalizerFormatter(); + + $this->context = config('slack-notifier.context', [ + 'get', 'post', 'cookie', 'session', + ]); + + $this->dontFlash = config('slack-notifier.dont_flash', [ + 'current_password', + 'password', + 'password_confirmation', + ]); + + $this->dontTrace = config('slack-notifier.dont_trace', [ + '/vendor/symfony/', + '/vendor/laravel/framework/', + '/vendor/barryvdh/laravel-debugbar/', + ]); + } + + public function format(SendToSlack $notification): SlackMessage + { + if ($exception = $notification->getException()) { + $slackMessage = $this->formatException($exception); + } else { + $slackMessage = $this->formatVariable($notification->getVariable()); + } + + $slackMessage->attachment(function (SlackAttachment $attachment) { + if (! $context = $this->getContext()) { + return; + } + + $attachment->pretext('Context') + ->content('```'.$context.'```') + ->color('#3498DB') + ->markdown(['text']); + }); + + return $slackMessage; + } + + protected function formatException(Throwable $exception, ?SlackMessage $slackMessage = null): SlackMessage + { + $pretext = $this->getPretext($exception); + + if ($slackMessage) { + $pretext = 'Previous exception'; + } + + $this->message->error(); + + $this->message->attachment(function (SlackAttachment $attachment) use ($exception, $pretext) { + $content = $this->normalize(get_class($exception).': '.$exception->getMessage().' in '.$exception->getFile().':'.$exception->getLine()); + + $attachment->pretext($pretext) + ->content('```'.$content.'```') + ->fallback(config('app.name').': '.$content) + ->markdown(['text']); + }); + + $this->message->attachment(function (SlackAttachment $attachment) use ($exception) { + $attachment->pretext('Stack trace') + ->content('```'.$this->normalizeTrace($exception).'```'); + }); + + if ($previous = $exception->getPrevious()) { + return $this->formatException($previous, $this->message); + } + + return $this->message; + } + + protected function formatVariable($variable): SlackMessage + { + $this->message->success(); + + $variable = $this->normalizeToString($variable); + + $this->message->attachment(function (SlackAttachment $attachment) use ($variable) { + $attachment->pretext($this->getPretext($variable)) + ->content('```'.$variable.'```') + ->fallback(config('app.name').': '.$variable) + ->markdown(['text']); + }); + + return $this->message; + } + + protected function getPretext($variable): string + { + $source = app()->runningInConsole() ? 'console' : request()->url(); + + if ($variable instanceof Throwable) { + return 'Caught an exception from '.$source; + } + + return 'Received value from '.$source; + } + + protected function normalize($data) + { + if (method_exists($this->normalizer, 'normalizeValue')) { + return $this->normalizer->normalizeValue($data); + } + + $r = new ReflectionMethod($this->normalizer, 'normalize'); + $r->setAccessible(true); + + return $r->invoke($this->normalizer, $data); + } + + protected function normalizeToString($variable): string + { + $variable = $this->normalize($variable); + + try { + if (is_null($variable)) { + $string = 'null'; + } elseif (is_bool($variable)) { + $string = $variable ? 'true' : 'false'; + } elseif (is_array($variable)) { + $string = print_r($variable, true); + } elseif (is_object($variable)) { + $string = json_encode($variable); + } else { + $string = (string) $variable; + } + } catch (Throwable $e) { + $string = 'Failed to normalize variable.'; + } + + return $string; + } + + protected function normalizeTrace(Throwable $exception): string + { + $emptyLineCharacter = ' ...'; + $lines = explode("\n", $exception->getTraceAsString()); + $filteredLines = []; + + foreach ($lines as $line) { + $shouldExclude = false; + foreach ($this->dontTrace as $excludePattern) { + if (str_starts_with($line, '#') && str_contains($line, $excludePattern)) { + $shouldExclude = true; + break; + } + } + + if ($shouldExclude && end($filteredLines) !== $emptyLineCharacter) { + $filteredLines[] = $emptyLineCharacter; + } elseif (! $shouldExclude) { + $filteredLines[] = $line; + } + } + + return implode("\n", $filteredLines); + } + + protected function getContext(): ?string + { + if (app()->runningInConsole()) { + return null; + } + + $context = []; + + foreach ($this->context as $item) { + $value = null; + $format = '$_%s = %s'; + + if ($item === 'get') { + $value = request()->query(); + } elseif ($item === 'post') { + $value = request()->post(); + } elseif ($item === 'request') { + $value = request()->all(); + } elseif ($item === 'headers') { + $value = request()->headers->all(); + } elseif ($item === 'files') { + $value = request()->allFiles(); + } elseif ($item === 'cookie') { + $value = request()->cookie(); + } elseif ($item === 'session' && request()->hasSession()) { + $value = request()->session()->all(); + } elseif ($item === 'server') { + $value = request()->server(); + } + + if (is_array($value) && ($value = Arr::except($value, $this->dontFlash))) { + $context[] = sprintf( + $format, + strtoupper($item), + print_r($this->normalize($value), true) + ); + } + } + + return implode("\n", $context); + } +} diff --git a/src/SlackNotifierServiceProvider.php b/src/SlackNotifierServiceProvider.php new file mode 100644 index 0000000..4a026d1 --- /dev/null +++ b/src/SlackNotifierServiceProvider.php @@ -0,0 +1,32 @@ +registerConfig(); + } + + public function boot(): void + { + if ($this->app->runningInConsole()) { + $this->publishConfigs(); + } + } + + protected function registerConfig(): void + { + $this->mergeConfigFrom(__DIR__.'/../config/slack-notifier.php', 'slack-notifier'); + } + + protected function publishConfigs(): void + { + $this->publishes([ + __DIR__.'/../config/slack-notifier.php' => config_path('slack-notifier.php'), + ], 'slack-notifier'); + } +} diff --git a/tests/ConfigTest.php b/tests/ConfigTest.php new file mode 100644 index 0000000..9d7d351 --- /dev/null +++ b/tests/ConfigTest.php @@ -0,0 +1,20 @@ +set('slack-notifier.webhook_urls.default', 'https://default-domain.com'); +}); + +it('can get a webhook url', function (string $name, string $result) { + $url = Config::getWebhookUrl($name); + + $this->assertSame($url, $result); +})->with([ + ['default', 'https://default-domain.com'], + ['https://custom-domain.com', 'https://custom-domain.com'], +]); + +it('cannot get a webhook url for an unknown config name', function () { + expect(Config::getWebhookUrl('non-existing'))->toBeNull(); +}); diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 0000000..2e33c33 --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,5 @@ +in(__DIR__); diff --git a/tests/SlackNotifierTest.php b/tests/SlackNotifierTest.php new file mode 100644 index 0000000..6a52a70 --- /dev/null +++ b/tests/SlackNotifierTest.php @@ -0,0 +1,119 @@ +set('slack-notifier.webhook_urls.default', 'https://default-domain.com'); + + SlackNotifier::send(new RuntimeException('test-exception')); + + Notification::assertSentTimes(SendToSlack::class, 1); +}); + +it('cannot send a notification with its fail to slack using the default webhook url', function () { + config()->set('slack-notifier.webhook_urls.default', 'https://default-domain.com'); + + SlackNotifier::send(WebhookSendFail::make(new RuntimeException('webhook is failed'))); + + Notification::assertNothingSent(); +}); + +it('can send a notification with a message to slack using the default webhook url', function () { + config()->set('slack-notifier.webhook_urls.default', 'https://default-domain.com'); + + SlackNotifier::send('test-data'); + + Notification::assertSentTimes(SendToSlack::class, 1); +}); + +it('can send a notification with an array to slack using the default webhook url', function () { + config()->set('slack-notifier.webhook_urls.default', 'https://default-domain.com'); + + SlackNotifier::send([ + 'test-key' => 'test-data', + ]); + + Notification::assertSentTimes(SendToSlack::class, 1); +}); + +it('can send a notification with an object to slack using the default webhook url', function () { + config()->set('slack-notifier.webhook_urls.default', 'https://default-domain.com'); + + SlackNotifier::send(new stdClass()); + + Notification::assertSentTimes(SendToSlack::class, 1); +}); + +it('can send a notification with null to slack using the default webhook url', function () { + config()->set('slack-notifier.webhook_urls.default', 'https://default-domain.com'); + + SlackNotifier::send(null); + + Notification::assertSentTimes(SendToSlack::class, 1); +}); + +it('can send a notification with boolean to slack using the default webhook url', function () { + config()->set('slack-notifier.webhook_urls.default', 'https://default-domain.com'); + + SlackNotifier::send(true); + + Notification::assertSentTimes(SendToSlack::class, 1); +}); + +it('can send a notification with a message to slack using an alternative webhook url', function () { + config()->set('slack-notifier.webhook_urls.testing', 'https://default-domain.com'); + + SlackNotifier::to('testing')->send('test-data'); + + Notification::assertSentTimes(SendToSlack::class, 1); +}); + +it('can send a notification with a message to slack alternative channel', function () { + config()->set('slack-notifier.webhook_urls.default', 'https://default-domain.com'); + + SlackNotifier::channel('random')->send('test-data'); + + Notification::assertSentTimes(SendToSlack::class, 1); +}); + +it('will throw an exception for a non-existing formatter class', function () { + config()->set('slack-notifier.webhook_urls.default', 'https://default-domain.com'); + config()->set('slack-notifier.formatter', 'non-existing-job'); + + SlackNotifier::send('test-data'); +})->throws(FormatterClassDoesNotExist::class); + +it('will not throw an exception for an empty webhook url', function () { + config()->set('slack-notifier.webhook_urls.default', ''); + + SlackNotifier::send('test-data'); +})->expectNotToPerformAssertions(); + +it('will throw an exception for an invalid webhook url', function () { + config()->set('slack-notifier.webhook_urls.default', 'not-an-url'); + + SlackNotifier::send('test-data'); +})->throws(WebhookUrlNotValid::class); + +it('will throw an exception for an invalid formatter class', function () { + config()->set('slack-notifier.webhook_urls.default', 'https://default-domain.com'); + config()->set('slack-notifier.formatter', ''); + + SlackNotifier::send('test-data'); +})->throws(FormatterClassDoesNotExist::class); + +it('will throw an exception for a missing formatter class', function () { + config()->set('slack-notifier.webhook_urls.default', 'https://default-domain.com'); + config()->set('slack-notifier.formatter', null); + + SlackNotifier::send('test-data'); +})->throws(FormatterClassDoesNotExist::class); diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..a938255 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,16 @@ +