Welcome to Part 2.
If you’ve no idea what I’m talking about here, go back and read this.
Okay, good to go? Let’s do this.
At this point we’ve got a FeedProcessorFactory.
The reason? Essentially all I want is a quick endpoint I query which returns a collection of data from an uploaded feed.
Something like this:
public function process(Feed $feed): JsonResponse
{
$processor = FeedProcessorFactory::make($feed->path);
return $processor->toJson();
}At the point, nothing is happening other the correct concrete class (or an exception is thrown) is returned based on the extension of the file path given.
There’s 5 concrete classes in total, let’s complete the CSVProcessor concrete class and add tests for the interface in this article, then finish off the others later.
FeedProcessor Interface
First up, let’s scaffold the FeedProcessor (Interface) test. I’d say we want the following methods across the concrete classes.
toArray()- return an array of contents.toJson()- return contents in json format.process()- process the feed itself and create an object.get()- get the feed object itself.
test('FeedProcessor interface has toArray method signature', function () {
})->todo();
test('FeedProcessor interface has toJson method signature', function () {
})->todo();
test('FeedProcessor interface has process method signature', function () {
})->todo();
test('FeedProcessor interface has get method signature', function () {
})->todo();Let’s run the tests:
And then write the tests.
use App\Services\FeedProcessor\Interface\FeedProcessor;
beforeEach(function () {
$this->interface = new ReflectionClass(FeedProcessor::class);
});
test('FeedProcessor interface has toArray method signature', function () {
expect($this->interface->hasMethod('toArray'))->toBeTrue();
});
test('FeedProcessor interface has toJson method signature', function () {
expect($this->interface->hasMethod('toJson'))->toBeTrue();
});
test('FeedProcessor interface has process method signature', function () {
expect($this->interface->hasMethod('process'))->toBeTrue();
});
test('FeedProcessor interface has get method signature', function () {
expect($this->interface->hasMethod('get'))->toBeTrue();
});We’re essentially checking if the Interface has the required methods.
We run the test suite again, we get:
As expected, the tests fail, let’s add the method signatures so the tests pass.
interface FeedProcessor
{
public function get(): mixed; // let's leave this mixed for now.
public function toArray(): array;
public function toJson(): string;
public function process(): void;
}Run the tests again.
Awesome sauce, let’s move on.
CSV Processor
Let’s assume the following:
It implements
FeedProcessorIt accepts a file path via construct
It contains methods for:
process()– reads the file and parses the feed.get()– returns the processed object.toArray()– parses CSV into an array.toJson()– JSON representation of parsed data.
We'll also include an edge case for malformed CSV.
test('CsvProcessor implements FeedProcessor', function () {
})->todo();
test('CsvProcessor throws if file does not exist', function () {
})->todo();
test('CsvProcessor throws exception on malformed CSV row during process', function () {
})->todo();
test('CsvProcessor processes CSV and sets feed object', function () {
})->todo();
test('CsvProcessor get() returns the processed object', function () {
})->todo();
test('CsvProcessor toJson() returns JSON after processing', function () {
})->todo();
test('CsvProcessor toArray() returns array after processing', function () {
})->todo();Sweet! Let’s work through each test one by one.
CsvProcessor Implements FeedProcessor.
test('CsvProcessor implements FeedProcessor', function () {
$csvProcessor = new CsvProcessor('some.csv');
expect($csvProcessor)->toBeInstanceOf(FeedProcessor::class);
});That’s easy enough, and it should already work*, but let’s check:
*shocker, it didn’t work, but why?
Well… it’s because since we’ve added method signatures to the Interface, and since CsvProcessor implements it and doesn’t contain those methods, it throws a wobbler.
To allow us to kick the can further down the road, let’s add those methods and get them to return dummy data if need be.
class CsvProcessor implements FeedProcessor
{
public function get(): mixed
{
return [];
}
public function toArray(): array
{
return [];
}
public function toJson(): string
{
return '';
}
public function process(): void
{
}
}Fantastic. Let’s check it.
CsvProcessor throws if file does not exist.
Let’s write the test.
test('CsvProcessor throws if file does not exist', function () {
new CsvProcessor('some.csv');
})->throws(FileDoesNotExistException::class);And run the test:
The standard operating procedure is simply to get the test to work, so let’s just create the Exception.
use Exception;
class FileDoesNotExistException extends Exception
{
public function __construct(string $path)
{
parent::__construct("File does not exist: {$path}");
}
}And just throw an Exception within the CsvProcessor construct.
class CsvProcessor implements FeedProcessor
{
/**
* @throws FileDoesNotExistException
*/
public function __construct(string $filePath)
{
throw new FileDoesNotExistException($filePath);
}Ok good news is, it works, bad news is, it’s broken the first test.
Because we’re throwing the Exception always, we never get the opportunity to ascertain what interface it implements, let’s adjust the __construct.
public function __construct(string $filePath)
{
if (!file_exists($filePath)) {
throw new FileDoesNotExistException($filePath);
}
}And the first test so it references a file which actually exists.
test('CsvProcessor implements FeedProcessor', function () {
$csvProcessor = new CsvProcessor('./tests/fixtures/sample.csv');
expect($csvProcessor)->toBeInstanceOf(FeedProcessor::class);
});And run the tests again!
Sweet…. but since we’ve got this new custom Exception, let’s adjust the Factory and it’s test (from Part 1) to use it too.
public static function make(string $filePath): FeedProcessor
{
if (!file_exists($filePath)) {
throw new FileDoesNotExistException($filePath);
}
// rest of method here...
}
// Test
test('throws exception when file does not exist', function () {
FeedProcessorFactory::make("nonexistent/path/file.csv");
})->throws(FileDoesNotExistException::class);Next!
CsvProcessor throws exception on malformed CSV row during process.
We need some ‘bad’ data, let’s create a bad_sample.csv fixture.
Here are the file contents.
name,email,email
John
Jane,[email protected]As you can see, the CSV data is malformed (there’s duplicate headers), with this, let’s write the test.
test('CsvProcessor throws exception on malformed CSV row during process', function () {
new CsvProcessor("./tests/fixtures/bad_sample.csv");
})->throws(CSVIsMalformedException::class, 'CSV file is malformed: The header record contains duplicate column names.');And run the tests.
First, I update the constructor and promote the filePath variable to a class property and call the process() method.
/**
* @throws FileDoesNotExistException
*/
public function __construct(private string $filePath)
{
if (!file_exists($this->filePath)) {
throw new FileDoesNotExistException($this->filePath);
}
$this->process();
}We also need the new Exception.
use Exception;
class CSVIsMalformedException extends Exception
{
public function __construct(string $error)
{
parent::__construct("CSV file is malformed: {$error}");
}
}This allows us to process the file and figure out if its valid, let’s create the feed object and if there’s a problem, then throw the Exception within the process() method.
I don’t need to recreate the wheel here, I only need to ascertain if the CSV isn’t malformed, I decide to pull in this package: https://csv.thephpleague.com
It’s as simple as:
composer require league/csv:^9.23.0It’ll pull down the package into my vendor folder and I can crack on. I end up with this:
/**
* @throws CSVIsMalformedException
*/
public function process(): void
{
try {
$csv = Reader::createFromPath($this->filePath, 'r');
$csv->setHeaderOffset(0);
$records = iterator_to_array($csv->getRecords());
} catch (Exception $exception) {
throw new CSVIsMalformedException(
$exception->getMessage()
);
}
}With that in place, we run the test again.
CsvProcessor processes CSV and sets feed object.
I don’t actually think we need this test. I’m going to remove it. The test below should cover it. It’s fine to change your tests (or remove them completely) if they’re not needed. Moving on!
CsvProcessor get() returns the processed object.
Should be easy enough. Let’s write the test, change the process method to set a class property, then have the get() method return it.
// Class property
private Reader $dataObject;
// Process method
$this->dataObject = Reader::createFromPath($this->filePath, 'r');
$this->dataObject->setHeaderOffset(0);
// Get method
public function get(): Reader
{
return $this->dataObject;
}
// Test
test('CsvProcessor get() returns the processed object', function () {
$csvProcessor = new CsvProcessor('./tests/fixtures/sample.csv');
$dataObject = $csvProcessor->get();
expect($dataObject)->toBeInstanceOf(Reader::class);
});Run the test…
Great, two left!
CsvProcessor toJson() returns JSON after processing && CsvProcessor toArray() returns array after processing.
I’m going to quickly do these (I’m running out of characters).
Final test file looks like this.
test('CsvProcessor implements FeedProcessor', function () {
$csvProcessor = new CsvProcessor('./tests/fixtures/sample.csv');
expect($csvProcessor)->toBeInstanceOf(FeedProcessor::class);
});
test('CsvProcessor throws if file does not exist', function () {
new CsvProcessor('some.csv');
})->throws(FileDoesNotExistException::class);
test('CsvProcessor throws exception on malformed CSV row during process', function () {
new CsvProcessor("./tests/fixtures/bad_sample.csv");
})->throws(CSVIsMalformedException::class, 'CSV file is malformed: The header record contains duplicate column names.');
test('CsvProcessor get() returns the processed object', function () {
$csvProcessor = new CsvProcessor('./tests/fixtures/sample.csv');
$dataObject = $csvProcessor->get();
expect($dataObject)->toBeInstanceOf(Reader::class);
});
test('CsvProcessor toJson() returns JSON after processing', function () {
$csvProcessor = new CsvProcessor('./tests/fixtures/sample.csv');
$json = $csvProcessor->toJson();
$data = json_decode($json, true);
expect(json_last_error())
->toBe(JSON_ERROR_NONE)
->and($data)->toBeArray()
->and(count($data))->toBe(478);
});
test('CsvProcessor toArray() returns array after processing', function () {
$csvProcessor = new CsvProcessor('./tests/fixtures/sample.csv');
$records = $csvProcessor->toArray();
expect($records)
->toBeArray()
->and(count($records))
->toBe(478);
});And here’s the finished CsvProcessor class.
class CsvProcessor implements FeedProcessor
{
private Reader $dataObject;
private array $records = [];
/**
* @throws FileDoesNotExistException
*/
public function __construct(private string $filePath)
{
if (!file_exists($this->filePath)) {
throw new FileDoesNotExistException($this->filePath);
}
$this->process();
}
/**
* @throws CSVIsMalformedException
*/
public function process(): void
{
try {
$this->dataObject = Reader::createFromPath($this->filePath, 'r');
$this->dataObject->setHeaderOffset(0);
$this->records = iterator_to_array($this->dataObject->getRecords());
} catch (Exception $exception) {
throw new CSVIsMalformedException(
$exception->getMessage()
);
}
}
public function get(): Reader
{
return $this->dataObject;
}
public function toArray(): array
{
return $this->records;
}
public function toJson(): string
{
return json_encode($this->records);
}
}Watch out for part 3. I’ll wrap up the other concrete classes, then maybe in part 4 we’ll stick it into a publicly accessible package for everyone to use.
Let’s finish this article with a bunch of green ticks.












