
[{"content":" AI \u0026amp; Automation # Knowing the difference will save you time \u0026amp; money. # AI \u0026amp; Automation are powerful tools that can benefit any business of any size but they can also be a money-pits if you don\u0026rsquo;t have a clear understanding of the options and the ROI you would like to achieve.\nKnowing where to start is never easy but made so much harder by the outlandish claims coming from the big AI industry players as they race for dominance and ever more investment.\nBig tech likes to throw around big questions like:\nWill it take or enhance your job ? Will it create new opportunities ? Will it destroy or revolutionise your business ? But conveniently skip the crucial ones:\nWhat can AI actually do ? Will it give you ROI ? They do this because they don\u0026rsquo;t really know themselves. The dream being sold is just one aspect of AI that can act independently like a human when the real ROI for most of us lies in much simpler automations and ai-automations. Think of a washing machine, it\u0026rsquo;s not clever but it is so much better at washing clothes than us humans. Why buy a robot that may or may not be able to wash clothes, do the dishes and clean when a washing machine, dish washer \u0026amp; hoover will do that in a more reliable \u0026amp; cost-effective way ?\nAutomations # Automations are simple bots, they don\u0026rsquo;t use LLMs \u0026amp; don\u0026rsquo;t cost much to build or run. They\u0026rsquo;ve become the overlooked child of the industry because they already exist and already work so can\u0026rsquo;t attract big investor money.\nLike a washing machine they\u0026rsquo;ll do one thing far better \u0026amp; quicker than a human. They aren\u0026rsquo;t going to change life as we know it but they\u0026rsquo;re a reliable tools that are cheap to run and will certainly produce ROI.\nAn example of a typical payroll automation is a bot that downloads payslips from a payroll provider then uploads those payslips to an employees account saving time and reducing human error.\nAI Automations # AI Automations also do one thing but make use of ai tools to get some or all of the task done. They cost more to run as they make use of AI tools like OCR and LLMs to scan images or extract useful data. They also do one thing well so reliably and consistently produce ROI.\nAn example of a typical accounts automation would be a bot that downloads invoices from many different suppliers, uses AI tools like Optical Character Recognition and LLMs to extract the core data from the invoice before putting that data into a spreadsheet or maybe sendiing it straight into an accounts package.\nAI Agents # These are more complex and the dream we all hear about on Social Media and linkedin. Rather than bots they\u0026rsquo;re refered-to as agents because they use tools like LLMs to make decisions and accomplish multiple tasks often working in groups together. That sounds complex because they are. They\u0026rsquo;re also costly to build and the source of comically-gross errors we all read about. ROI is hard to call and the statistic you\u0026rsquo;ve probably read that 95% of corporate AI initiatives fail - that\u0026rsquo;s mostly down to AI agents.\n","date":"1 January 2026","externalUrl":null,"permalink":"/ai-automations/","section":"","summary":"","title":"","type":"ai-automations"},{"content":" Automation Without the Baggage # Building and maintaining your own automation infrastructure is heavy lifting you don\u0026rsquo;t need. With Automation as a Service , you get all the power of a digital workforce—without the headache of managing the tech. We build it, run it, and monitor it 24/7. It\u0026rsquo;s a scalable, subscription-based model that frees you from \u0026ldquo;broken\u0026rdquo; bots and outdated software.\nWhy AaaS? # Running in Weeks, Not Months: We skip the long IT projects. Our team maps and automates your tedious tasks at lightning speed, so you see ROI almost immediately. Total Maintenance Included: When a system update drops or a website layout shifts, we fix the bot before you even notice. You pay for results, not repairs. Scaling on Demand: Need to process 1,000 extra invoices this month? Your digital workers scale instantly to meet the volume—no hiring or training required. ","date":"1 January 2026","externalUrl":null,"permalink":"/ai-automations/automation-as-a-service/","section":"","summary":"We build AI and Automations bots","title":"Automation as a Service","type":"ai-automations"},{"content":" Stop Guessing. Start Automating. # Most organizations know their workflows are inefficient, but they can\u0026rsquo;t pinpoint where money is leaking. Our Automation Consultancy doesn\u0026rsquo;t just hand you a \u0026ldquo;best of luck\u0026rdquo; report—we provide the roadmap to eliminate donkey work for good. Whether you\u0026rsquo;re fixing a single department or building an entire internal automation engine, we clear the hurdles so you can focus on growth.\nHow We Clear the Drag # Process Discovery : We dive into your daily operations to identify repetitive tasks ripe for automation. You get a clear picture of exactly how much time and money you\u0026rsquo;ll save before we even start. Process Mining : We use actual data to map your workflows, exposing hidden bottlenecks and errors that a human eye would miss. Automation Roadmap : We build you a roadmap that takes you from zero to no more donkey work. Building Your Center of Excellence (CoE) : We help you set up your own in-house automation department, training your team to identify, build, and manage bots so you become self-sufficient. ","date":"1 January 2026","externalUrl":null,"permalink":"/ai-automations/automation-consultancy/","section":"","summary":"We help you understand how AI and Automation can help you - free consultancy","title":"Automation Consultancy","type":"ai-automations"},{"content":"","date":"1 January 2026","externalUrl":null,"permalink":"/","section":"Digital Donkeys","summary":"","title":"Digital Donkeys","type":"page"},{"content":"View articles by tag\n","date":"12 December 2025","externalUrl":null,"permalink":"/articles/","section":"","summary":"","title":"","type":"articles"},{"content":"","date":"12 December 2025","externalUrl":null,"permalink":"/authors/","section":"Authors","summary":"","title":"Authors","type":"authors"},{"content":"","date":"12 December 2025","externalUrl":null,"permalink":"/tags/claude-code/","section":"Tags","summary":"","title":"Claude Code","type":"tags"},{"content":"","date":"12 December 2025","externalUrl":null,"permalink":"/tags/rspec/","section":"Tags","summary":"","title":"Rspec","type":"tags"},{"content":"","date":"12 December 2025","externalUrl":null,"permalink":"/series/","section":"Series","summary":"","title":"Series","type":"series"},{"content":"","date":"12 December 2025","externalUrl":null,"permalink":"/series/spec-tdd-claude/","section":"Series","summary":"","title":"Spec-Tdd-Claude","type":"series"},{"content":"","date":"12 December 2025","externalUrl":null,"permalink":"/authors/steve-creedon/","section":"Authors","summary":"","title":"Steve Creedon","type":"authors"},{"content":"","date":"12 December 2025","externalUrl":null,"permalink":"/authors/stevecreedon/","section":"Authors","summary":"","title":"Stevecreedon","type":"authors"},{"content":"","date":"12 December 2025","externalUrl":null,"permalink":"/tags/","section":"Tags","summary":"","title":"Tags","type":"tags"},{"content":"","date":"12 December 2025","externalUrl":null,"permalink":"/tags/tdd/","section":"Tags","summary":"","title":"TDD","type":"tags"},{"content":" What\u0026rsquo;s it all about # This is a five part series of articles where I explore how Claude can be used to create code using RSpec and Test Driven Design (TDD):\nPart 1: I talk about the importance of automated testing and Test Driven Development Part 2: Before going full TDD I get Claude to write some RSpec test for existing code and found some interesting surprises there. Part 3: I define a Problem and Solution Spaces that I want to solve using new code written in a TDD style Part 4: I create the tests and code for the utility class defined in my Solution Space using TDD Part 5: I create the first tests and code for the core class defined in my Solution Space using TDD Part 6: I complete the tests and code for the core class defined in my Solution Space using TDD Why Claude Code \u0026amp; TDD ? # I\u0026rsquo;ve been coding for some 20 years now and worked with LLMs way before most had even heard the phrase. Despite that, when it came to using AI coding tools I struggled to find a good way to start and this wasn\u0026rsquo;t helped by all the \u0026ldquo;You just have to trust me it\u0026rsquo;s freakin\u0026rsquo; awesome\u0026rdquo; fanboys on linkedin.\nI messed around with Github co-pilot for a while but it didn\u0026rsquo;t really give me a feel of anything amazing about to happen anytime soon though my Visual Studio autocomplete began to impress me rather than just annoy me - it still does annoy me from time to time.\nNot quite knowing where to start may also be down to my tool of choice - Ruby \u0026amp; Ruby on Rails. Rails is a web framework already optimised for rapid development with minimal boilerplate so there wasn\u0026rsquo;t much in the way of easy wins for an AI tool. Moreover, co-pilot often gave me code that didn\u0026rsquo;t make best use of the language or framework. It was Ruby code \u0026ldquo;but not as we know it\u0026rdquo; tand it worked in its own oddly artificial way.\nTinkering with Claude I found that it gave me ruby code mostly in a style similar to my own so I put more of my efforts there. That said, I was still a little unsure on the best way to start until it occured to me that Test Driven Development is not unlike using AI to develop code. A prompt is a way of telling the AI model what you want the code to do. Test Driven Development starts with a statement on the problem the code needs to solve.\nI\u0026rsquo;m reluctant to allow agents to write more code than I can effectively check. I can\u0026rsquo;t prove this yet - though maybe I\u0026rsquo;ll try one day - but I\u0026rsquo;d bet good money that if an agent writes me 10 lines of code that takes me 2 minutes to check it will take me a lot longer than 4 minutes to check 20 lines of code so to me it feels faster to wrote less in one go. Writing AI code in a TDDish style felt like a good way to keep things small and ensure:\nI\u0026rsquo;m able to review and critique both tests \u0026amp; production code quickly and effectively. I don\u0026rsquo;t become an LLM zombie. My hope is that I can use LLMs to do the dull, laborious code that Claude has already seen a million times while giving me more time to focus on the more novel code solutions it\u0026rsquo;s unlikely to have seen before. TLDR # In case you\u0026rsquo;re not familiar with the term it means \u0026lsquo;Too Long Don\u0026rsquo;t Read\u0026rsquo;. This article comes in six parts so if you don\u0026rsquo;t have the time to read them all then I thought I\u0026rsquo;d just share my conclusions up front.\nThe Good # First and foremost I really enjoyed the experience, especially since Claude handled some laborious tasks on large AWS documents that I know I would have found tedious so probably not done as good a job. I learned a few eloquent railsisms that I didn\u0026rsquo;t know existed and this feels like a nice aspect of AI. I learned my core skills over a certain time so haven\u0026rsquo;t always adopted newer things if the way I already knew worked. For sure it saved me a significant amount of time but required more time up-front thinking about the problem and what I wanted the code to do. This is good - \u0026ldquo;weeks of coding can save you hours of planning\u0026rdquo; and I\u0026rsquo;m confident the end result contributed less to technical debt than had I just gone in feet-first \u0026amp; muddled through, writing tests along the way. Generally Claude wrote code in a nice Rubyesque way. Occasionally it would do something that could be better but never so, much better that I\u0026rsquo;d feel the need to change it. I was very impressed when I forgot to give a name to a method when prompting it to generate code and it came up with an appropriate name all by itself. It came up with some effective and novel (novel to me at least) ways of testing that some complex and highly structured data no longer existed by first checking it did exist, running the production code then checking it didn\u0026rsquo;t exist. It wrote some nice utility methods to keep the tests DRY. The not so Good # In all honesty there was nothing bad in the production code that I felt I had to change. I had to tell it to use \u0026lsquo;\u0026lsquo;subject\u0026quot; in my tests instead of assigning another variable name - using \u0026lsquo;subject\u0026rsquo; is important to me as makes it clear what is being tested. Some tests were really testing the underlying framework more than the code. I had to instruct it to use fixture and factories rather than create its own test data in the spec but only had to do that once so it learned. Some tests were testing the same thing in different ways and occasionally the underlying framework. I kept them in but would have been equally happy just deleting them. Let\u0026rsquo;s Get Started - Talk About Testing # Test Code is more important than Production Code # I\u0026rsquo;ve have always valued test code over production code.\nWell written specs tell you in plain human language what each and every part of the application should do and also help think about edge cases. The test code ensures that each and every every part of the production code conforms to that specification so even if the code is a bit pants you can live with it knowing it works. Not all code has to be match fit. Test code is a highly effective enemy of technical debt. When requirements change or you just can\u0026rsquo;t bear to look at your pants-code any more having tests enables you to cut out anything that\u0026rsquo;s no-longer fit for purpose quickly and at low cost. What does an automated test look like ? # In case you\u0026rsquo;re not familiar with automated tests here\u0026rsquo;s a simple example in Ruby in which a fictitious class either returns laughter or nothing dependent on what is passed to Joke#deliver method.\ndescribe Joke do let(:gag){ \u0026#34;Two fish in a tank, one says to the other - you drive I\u0026#39;ll man the guns.\u0026#34; } let(:lecture){ \u0026#34;When I was your age we never used to...\u0026#34; } subject{ described_class.new } it \u0026#34;returns laughter when the argument is funny\u0026#34; do expect(subject.deliver(gag)).to eq \u0026#34;laughter\u0026#34; end it \u0026#34;returns nil when the argument is NOT funny\u0026#34; do expect(subject.deliver(lecture)).to be nil end it \u0026#39;raises an ArgumentError whenever nil is passed as an argument\u0026#39; do expect{ subject.deliver(nil)) }.to raise_error(ArgumentError) end end What is Test Driven Development ? # TDD is a process where a human defines the problem that code should solve before writing that code. For anyone outside software development that may seem painfully obvious but it\u0026rsquo;s something surprisingly few software developers actually do - it\u0026rsquo;s all too easy to start writing code without the faintest clue what finished looks like and I\u0026rsquo;m as guilty as the next person.\nThe risks associated with unplanned coding can be mitigated by writing automated tests. I always tell developers \u0026ldquo;I don\u0026rsquo;t care how you do it as long as you write the f*cking tests\u0026rdquo; and I do live by that myself. I tend to dip my toe into the problem by writing a little bit of production code, then a few tests, then back to production code, all the time wobbling forward in some three-legged race with my tests one side and production code the other. From my limited experience I do feel it likely that I would have been a lot quicker and code more elegant had I the discipline to practice TDD.\nDone well TDD should be an elegant flow of human ideas into code:\nDefine the test: \u0026ldquo;returns laughter when given a gag\u0026rdquo; Write the test: expect(subject.deliver(gag)).to return(\u0026quot;laughter\u0026quot;) Write the production code. It occured to me that this isn\u0026rsquo;t so different to using an LLM to write code.\nPrompt the LLM by telling it what you want the code to achieve. The LLM writes the test code. The LLM writes the production code What I like about AI coding is the way that it FORCES me to describe what I want rather than gives me the option. I suspect this is a fundamental differentiator. Devs that like to think about what they want to build will find AI adoption easier than those that start writing code and work it out as they go along.\nNext Up # I start with baby steps by asking Claude to write tests for some existing code rather than go feet first into TDD.\n","date":"12 December 2025","externalUrl":null,"permalink":"/articles/tdd-with-rspec-and-claude-1-the-importance-of-tdd/","section":"","summary":"First impressions using AI for Test Driven Development","title":"TDD with Rspec \u0026 Claude - Part 1 - The Importance of Tests.","type":"articles"},{"content":"In this part I write tests for existing code rather than attempt the full TDD cycle of problem statement \u0026gt; test \u0026gt; code. If you want to jump straight to TDD then go to the next section. Things did get interesting even with this simple start so do read if you can.\n[toc]\nBaby Steps - Testing without TDD # When it comes to writing tests I\u0026rsquo;m an unashamed pedant but that doesn\u0026rsquo;t mean I always write them. Tests are at their best when you know what you want to build but when you\u0026rsquo;re exploring something new they get in the way:\nHow do you write a test for something when you have no idea what that something looks like ? As you discover what that something does look like you\u0026rsquo;ll probably have to go back and change things quite often so tests can get in the way. I\u0026rsquo;ve been working on a side-hustle, expensehound.com. It\u0026rsquo;s a Ruby on Rails 8.1 app that takes bulk uploads of images, runs them through amazon textract, sends the output to ChatGPT then cleans the data before putting all that through our own, home baked python based text classifier.\nThis functionality doesn\u0026rsquo;t sit naturally within a web framework like Rails which is designed to handle single http request rather than orchestrate a series of multiple, long running data tasks. I had to put much of the code into background jobs chained together by events but Rails 8.1 really is amazing for really rapid development so my intention was to create a POC in Rails then move the data heaving lifting out.\nWhat started as a spike became a system that was surpisingly robust even under heavy load. I was faced with a common software paradox - my prototype was suddenly production ready but lacking essential practices like tests.\nIf you\u0026rsquo;re wondering why that\u0026rsquo;s such a problem, lack of tests make it hard to change anything without risk of breaking everything. Code in production with real users but without tests is hard to change without risking serious reputational damage.\nThe Problem # I have a class CsvUpload, that represents a csv file uploaded to the site by a customer. Each row in the csv will generate a CsvRecord that has an attached image. Each image is downloaded through its own background job so I wrote a method that would tell me if all of the images have been downloaded - basically does the number csv records with a download match the number of csv records.\nThis is the method:\nhas_many :csv_records, dependent: :destroy def receipts_downloaded? csv_records.receipt_downloaded.count == csv_records.count end and in the child csv_record class there is a scope:\nscope :receipt_downloaded, -\u0026gt; { joins(:expense_image_attachment) } Prompt to write a spec for the existing receipts_downloaded? Method # My prompt was deliberately simple just to make Claude think:\nwrite a spec for the csv upload model that tests the receipts_downloaded? method\nThe results weren\u0026rsquo;t bad at all. It wasn\u0026rsquo;t perfect, I\u0026rsquo;ve written the issues below this code:\n# == Schema Information # # Table name: csv_uploads # Database name: primary # # id :integer not null, primary key # llm_complete :boolean # ocr_complete :boolean # created_at :datetime not null # updated_at :datetime not null # user_id :integer # require \u0026#39;rails_helper\u0026#39; RSpec.describe CsvUpload, type: :model do describe \u0026#39;#receipts_downloaded?\u0026#39; do ###################################################################### # BAD: it\u0026#39;s hasn\u0026#39;t used # subject { described_class.new } # to create an instance of the class being tested but an instance of csv_upload factory: # let(:csv_upload) { create(:csv_upload) } ###################################################################### let(:csv_upload) { create(:csv_upload) } ###################################################################### # GOOD: it\u0026#39;s picked up that I have csv_record factory and used that: # create(:csv_record, csv_upload: csv_upload) # github co-pilot seemed to prefer mocks over test fixtures ###################################################################### context \u0026#39;when all csv_records have expense_image attached\u0026#39; do it \u0026#39;returns true\u0026#39; do csv_record_1 = create(:csv_record, csv_upload: csv_upload) csv_record_2 = create(:csv_record, csv_upload: csv_upload) csv_record_3 = create(:csv_record, csv_upload: csv_upload) csv_record_1.expense_image.attach( io: StringIO.new(\u0026#39;fake image data 1\u0026#39;), filename: \u0026#39;receipt1.jpg\u0026#39;, content_type: \u0026#39;image/jpeg\u0026#39; ) csv_record_2.expense_image.attach( io: StringIO.new(\u0026#39;fake image data 2\u0026#39;), filename: \u0026#39;receipt2.jpg\u0026#39;, content_type: \u0026#39;image/jpeg\u0026#39; ) csv_record_3.expense_image.attach( io: StringIO.new(\u0026#39;fake image data 3\u0026#39;), filename: \u0026#39;receipt3.jpg\u0026#39;, content_type: \u0026#39;image/jpeg\u0026#39; ) expect(csv_upload.receipts_downloaded?).to be true end end ######################################################################## # GREAT: it\u0026#39;s got the mocking of attachments spot-on: # csv_record_1.expense_image.attach( # io: StringIO.new(\u0026#39;fake image data\u0026#39;), # filename: \u0026#39;receipt1.jpg\u0026#39;, # content_type: \u0026#39;image/jpeg\u0026#39; # ) # it took me AGES to work that one out for myself first time I did it. ######################################################################### context \u0026#39;when only some csv_records have expense_image attached\u0026#39; do it \u0026#39;returns false\u0026#39; do csv_record_1 = create(:csv_record, csv_upload: csv_upload) csv_record_2 = create(:csv_record, csv_upload: csv_upload) csv_record_3 = create(:csv_record, csv_upload: csv_upload) csv_record_1.expense_image.attach( io: StringIO.new(\u0026#39;fake image data\u0026#39;), filename: \u0026#39;receipt1.jpg\u0026#39;, content_type: \u0026#39;image/jpeg\u0026#39; ) csv_record_2.expense_image.attach( io: StringIO.new(\u0026#39;fake image data\u0026#39;), filename: \u0026#39;receipt2.jpg\u0026#39;, content_type: \u0026#39;image/jpeg\u0026#39; ) # csv_record_3 has no attachment expect(csv_upload.receipts_downloaded?).to be false end end context \u0026#39;when no csv_records have expense_image attached\u0026#39; do it \u0026#39;returns false\u0026#39; do create(:csv_record, csv_upload: csv_upload) create(:csv_record, csv_upload: csv_upload) expect(csv_upload.receipts_downloaded?).to be false end end context \u0026#39;when csv_upload has no csv_records\u0026#39; do it \u0026#39;returns true\u0026#39; do expect(csv_upload.csv_records.count).to eq(0) expect(csv_upload.receipts_downloaded?).to be true end end context \u0026#39;verifying the receipt_downloaded scope behavior\u0026#39; do it \u0026#39;correctly counts records with attachments using joins\u0026#39; do csv_record_with_image = create(:csv_record, csv_upload: csv_upload) csv_record_without_image = create(:csv_record, csv_upload: csv_upload) csv_record_with_image.expense_image.attach( io: StringIO.new(\u0026#39;fake image data\u0026#39;), filename: \u0026#39;receipt.jpg\u0026#39;, content_type: \u0026#39;image/jpeg\u0026#39; ) expect(csv_upload.csv_records.count).to eq(2) expect(csv_upload.csv_records.receipt_downloaded.count).to eq(1) expect(csv_upload.receipts_downloaded?).to be false end end context \u0026#39;with multiple file types\u0026#39; do it \u0026#39;returns true when all receipts are downloaded regardless of file type\u0026#39; do csv_record_1 = create(:csv_record, csv_upload: csv_upload) csv_record_2 = create(:csv_record, csv_upload: csv_upload) csv_record_1.expense_image.attach( io: StringIO.new(\u0026#39;fake pdf data\u0026#39;), filename: \u0026#39;receipt.pdf\u0026#39;, content_type: \u0026#39;application/pdf\u0026#39; ) csv_record_2.expense_image.attach( io: StringIO.new(\u0026#39;fake png data\u0026#39;), filename: \u0026#39;receipt.png\u0026#39;, content_type: \u0026#39;image/png\u0026#39; ) expect(csv_upload.receipts_downloaded?).to be true end end end end A Csv Record will always have an attached image (Claude wasn\u0026rsquo;t to know that) I feel strongly about the use of the variable subject to describe instances of the class being tested NOT let(:csv_upload) { create(:csv_upload) } when I look at a test and see subject not just another variable name then I know what is being tested. Prompt to fix the subject issue and clarify that every csv row will have a receipt image. # A csv_record will always have an image attached, use subject instead of the variable csv_record\nThis is where it started to get really interesting:\nThe first thing it did was suggest an update to my FactoryBot csv_record factory which generates test data. The suggestion was to add an after(:create) that ensured every object had an attached image. Good practice that makes sense and something I had overlooked:\n# == Schema Information # # Table name: csv_records # Database name: primary # # id :integer not null, primary key # amount :string # amount_cents :decimal(, ) # category :string # country :string # currency_code :string # date :date # employee_name :string # expense_text :string # expense_type :string # job_status :string # llm_complete :boolean # merchant :string # ocr_complete :boolean # status :string # url :string # warnings :text # created_at :datetime not null # updated_at :datetime not null # csv_upload_id :integer # employee_id :string # expense_id :string # job_id :string # FactoryBot.define do factory :csv_record do csv_upload amount { \u0026#34;123.45\u0026#34; } category { \u0026#34;Travel\u0026#34; } country { Faker::Address.country } currency_code { Faker::Currency.code } date { Date.civil(2025, 11, 11) } employee_id { Faker::Code.asin } employee_name { Faker::Name.name } expense_id { Faker::Code.asin } expense_text { Faker::Lorem.paragraph } expense_type { \u0026#34;Business\u0026#34; } job_id { Faker::Internet.uuid } job_status { \u0026#34;SUCCEEDED\u0026#34; } merchant { Faker::Company.name } status { \u0026#34;Approved\u0026#34; } url { \u0026#34;https://example.com/some-receipt.pdf\u0026#34; } ocr_complete { true } llm_complete { nil } association :customer ########################################################################## # GREAT: once I\u0026#39;d told it that a csv record always has an attached image # it added this to the factory ########################################################################## after(:create) do |csv_record| csv_record.expense_image.attach( io: StringIO.new(\u0026#39;fake receipt image data\u0026#39;), filename: \u0026#39;receipt.jpg\u0026#39;, content_type: \u0026#39;image/jpeg\u0026#39; ) end end end It then updated the csv_upload spec it had created removing all tests that assumed some or all csv_records had no attached image reducing it to something much more succinct and to the point. It also replaced csv_upload with subject as I;d asked:\n# == Schema Information # # Table name: csv_uploads # Database name: primary # # id :integer not null, primary key # llm_complete :boolean # ocr_complete :boolean # created_at :datetime not null # updated_at :datetime not null # user_id :integer # require \u0026#39;rails_helper\u0026#39; RSpec.describe CsvUpload, type: :model do describe \u0026#39;#receipts_downloaded?\u0026#39; do subject { create(:csv_upload) } ######################################################################################## # GREAT: it has removed the explicit creation of attachments now they are in the factory ######################################################################################## context \u0026#39;when all csv_records have expense_image attached\u0026#39; do it \u0026#39;returns true\u0026#39; do create(:csv_record, csv_upload: subject) create(:csv_record, csv_upload: subject) create(:csv_record, csv_upload: subject) expect(subject.receipts_downloaded?).to be true end end context \u0026#39;when csv_upload has no csv_records\u0026#39; do it \u0026#39;returns true\u0026#39; do expect(subject.csv_records.count).to eq(0) expect(subject.receipts_downloaded?).to be true end end context \u0026#39;when csv_upload has multiple csv_records\u0026#39; do it \u0026#39;correctly counts all downloaded receipts\u0026#39; do create_list(:csv_record, 5, csv_upload: subject) expect(subject.csv_records.count).to eq(5) expect(subject.csv_records.receipt_downloaded.count).to eq(5) expect(subject.receipts_downloaded?).to be true end end end end ################################################################################################### # GREAT: it has removed all of the tests based on an assumption that a csv record has no attachment # # NOT SO GREAT: it\u0026#39;s used \u0026#39;magic numbers\u0026#39; e.g. # expect(subject.csv_records.count).to eq(5) # I\u0026#39;d prefer a variable \u0026#39;csv_record_count\u0026#39; that at least describes what 5 means: # let{csv_record_count}{ 5 } # expect(subject.csv_records.count).to eq(csv_record_count) # But I can live with 5 so will let it go. ################################################################################################### lastly it ran the tests for me ensuring they all passed.\nThe good \u0026amp; the bad # I\u0026rsquo;d give Claude 9 out of 10. The good is mostly very good and the bad isn\u0026rsquo;t that bad.\nGood # Claude detected that I had a FactoryBot factory for my csv record class rather make up its own test data Claude created some good test code to mock an attached image - it took me ages to work that out for myself a few weeks ago. When I told Claude that every csv record will have an image it moved the mock image attachment code into the csv record factory and removed the tests that assumed there was no image. Bad # It didn\u0026rsquo;t use the standard \u0026lsquo;subject\u0026rsquo; variable name for the test being described but did fix that when prompted to do so. Seems fond of magic words and numbers e.g. expect(subject.csv_records.count).to eq(5) what does 5 mean ? Up Next # In the next article I start the TDD experiment by describing the Problem \u0026amp; Solution Space we need to solve and design.\n","date":"12 December 2025","externalUrl":null,"permalink":"/articles/tdd-with-rspec-and-claude-2-test-existing-code/","section":"","summary":"First impressions using AI for Test Driven Development","title":"TDD with Rspec \u0026 Claude - Part 2 - Writing RSpec tests for existing code.","type":"articles"},{"content":"Let\u0026rsquo;s start with a reminder that the aim of the exercise is to use an AI tool - in our case Claude - to build something in a TDDish way.\nIOW:\nSpecify what we want to happen Generate a test that ensures the code we are going to write actually does all the things we want to happen Write code that will pass the test. I\u0026rsquo;m less concerned about using AI to code faster. As an engineering leader I know the speed that matters most is \u0026lsquo;Speed to Market\u0026rsquo; - the time it takes for an idea in someone's head to become effective code in production.\nWhat impacts speed to market way more than speed of coding is technical debt - code that\u0026rsquo;s hard to read or change due to poor naming or design decisions.\nI\u0026rsquo;m hoping Claude will allow me to spend more time thinking about the problem and designing an effective solution \u0026amp; less time banging the keyboard. An added bonus would be consistent naming - I\u0026rsquo;ll be reading Claude\u0026rsquo;s chosen names (rather than my own) so they\u0026rsquo;ll need to be meaningful.\nIn the previous article in this series I used Claude to write tests for some code I wrote while spiking this expense problem. That spike has helped me understand the problem I want to solve so I will describe both problem and solution using the Domain Driven Design concepts of Problem Space and Solution Space\nProblem Space - what do we want to solve ? # Before we can specify anything we have to understand the problem we want to solve. I\u0026rsquo;ll start with a simple, fictitous receipt:\nWhen Textract reads this receipt it will return the content it found in two forms:\nSummary Fields - words like \u0026ldquo;Shopping Store\u0026rdquo;, \u0026ldquo;No Exchanges\u0026rdquo; and \u0026ldquo;TOTAL\u0026rdquo; that stand alone. Textract will attempt to identify the meaning of that text e.g. names, addresses, VAT numbers etc. Line Item Groups \u0026amp; Line Items - these are rows of texts within tables and typically the details of the receipt. A line item group is basically a table and a line item a row within that table. Whilst a simple receipt will only have one table of line items it\u0026rsquo;s possible a hotel or car hire bill may have multiple tables so Textract plays safe and gives us an array of tables. In the example above there is a single Line Item Group with Two Line Items Misc $0.49 Stuff $7.99 I\u0026rsquo;ll be sending this data to an LLM. I don\u0026rsquo;t want to send anything that may be sensitive such as a personal address so some of the Summary Fields such as \u0026ldquo;Name\u0026rdquo; or \u0026ldquo;Address\u0026rdquo; will be ignored when prompting the LLM.\nSolution Space # Now I\u0026rsquo;m thinking about the solution its important to base that on real data not just simplified data so I ran this slightly crumpled receipt through aws textract which produced a large JSON document with over 8000 lines.\nTake just the first line item in the receipt that reads \u0026ldquo;29368730 - PINE PASS VIT W - £2.30\u0026rdquo; that has over 150 lines of highly structured JSON.\n{ \u0026#34;line_item_expense_fields\u0026#34;: [ { \u0026#34;page_number\u0026#34;: 1, \u0026#34;type\u0026#34;: { \u0026#34;confidence\u0026#34;: 92.91315460205078, \u0026#34;text\u0026#34;: \u0026#34;PRODUCT_CODE\u0026#34; }, \u0026#34;value_detection\u0026#34;: { \u0026#34;confidence\u0026#34;: 92.88059997558594, \u0026#34;geometry\u0026#34;: { \u0026#34;bounding_box\u0026#34;: { \u0026#34;height\u0026#34;: 0.015923436731100082, \u0026#34;left\u0026#34;: 0.3871033191680908, \u0026#34;top\u0026#34;: 0.16613367199897766, \u0026#34;width\u0026#34;: 0.05253003537654877 }, \u0026#34;polygon\u0026#34;: [ { \u0026#34;x\u0026#34;: 0.38710713386535645, \u0026#34;y\u0026#34;: 0.16613367199897766 }, { \u0026#34;x\u0026#34;: 0.4392944872379303, \u0026#34;y\u0026#34;: 0.16638801991939545 }, { \u0026#34;x\u0026#34;: 0.4396333396434784, \u0026#34;y\u0026#34;: 0.18205711245536804 }, { \u0026#34;x\u0026#34;: 0.3871033191680908, \u0026#34;y\u0026#34;: 0.1818249672651291 } ] }, \u0026#34;text\u0026#34;: \u0026#34;29368730\u0026#34; } }, { \u0026#34;page_number\u0026#34;: 1, \u0026#34;type\u0026#34;: { \u0026#34;confidence\u0026#34;: 99.96350860595703, \u0026#34;text\u0026#34;: \u0026#34;ITEM\u0026#34; }, \u0026#34;value_detection\u0026#34;: { \u0026#34;confidence\u0026#34;: 99.95870971679688, \u0026#34;geometry\u0026#34;: { \u0026#34;bounding_box\u0026#34;: { \u0026#34;height\u0026#34;: 0.01568121463060379, \u0026#34;left\u0026#34;: 0.46603885293006897, \u0026#34;top\u0026#34;: 0.16891515254974365, \u0026#34;width\u0026#34;: 0.1018521711230278 }, \u0026#34;polygon\u0026#34;: [ { \u0026#34;x\u0026#34;: 0.46603885293006897, \u0026#34;y\u0026#34;: 0.16891515254974365 }, { \u0026#34;x\u0026#34;: 0.5667520761489868, \u0026#34;y\u0026#34;: 0.1693989783525467 }, { \u0026#34;x\u0026#34;: 0.5678910613059998, \u0026#34;y\u0026#34;: 0.18459635972976685 }, { \u0026#34;x\u0026#34;: 0.466538667678833, \u0026#34;y\u0026#34;: 0.18415427207946777 } ] }, \u0026#34;text\u0026#34;: \u0026#34;PINE\\nPASS VIT W\u0026#34; } }, { \u0026#34;page_number\u0026#34;: 1, \u0026#34;type\u0026#34;: { \u0026#34;confidence\u0026#34;: 99.99966430664062, \u0026#34;text\u0026#34;: \u0026#34;PRICE\u0026#34; }, \u0026#34;value_detection\u0026#34;: { \u0026#34;confidence\u0026#34;: 99.97443389892578, \u0026#34;geometry\u0026#34;: { \u0026#34;bounding_box\u0026#34;: { \u0026#34;height\u0026#34;: 0.013689628802239895, \u0026#34;left\u0026#34;: 0.6078405380249023, \u0026#34;top\u0026#34;: 0.17143931984901428, \u0026#34;width\u0026#34;: 0.039756517857313156 }, \u0026#34;polygon\u0026#34;: [ { \u0026#34;x\u0026#34;: 0.6078405380249023, \u0026#34;y\u0026#34;: 0.17143931984901428 }, { \u0026#34;x\u0026#34;: 0.6461372971534729, \u0026#34;y\u0026#34;: 0.17162123322486877 }, { \u0026#34;x\u0026#34;: 0.6475970149040222, \u0026#34;y\u0026#34;: 0.18512894213199615 }, { \u0026#34;x\u0026#34;: 0.6090853810310364, \u0026#34;y\u0026#34;: 0.18496116995811462 } ] }, \u0026#34;text\u0026#34;: \u0026#34;$2.30\u0026#34; } }, { \u0026#34;page_number\u0026#34;: 1, \u0026#34;type\u0026#34;: { \u0026#34;confidence\u0026#34;: 99.99983215332031, \u0026#34;text\u0026#34;: \u0026#34;EXPENSE_ROW\u0026#34; }, \u0026#34;value_detection\u0026#34;: { \u0026#34;confidence\u0026#34;: 99.98657989501953, \u0026#34;geometry\u0026#34;: { \u0026#34;bounding_box\u0026#34;: { \u0026#34;height\u0026#34;: 0.019723456352949142, \u0026#34;left\u0026#34;: 0.3871026039123535, \u0026#34;top\u0026#34;: 0.16613367199897766, \u0026#34;width\u0026#34;: 0.260573148727417 }, \u0026#34;polygon\u0026#34;: [ { \u0026#34;x\u0026#34;: 0.38710713386535645, \u0026#34;y\u0026#34;: 0.16613367199897766 }, { \u0026#34;x\u0026#34;: 0.6456804275512695, \u0026#34;y\u0026#34;: 0.16739386320114136 }, { \u0026#34;x\u0026#34;: 0.6476757526397705, \u0026#34;y\u0026#34;: 0.1858571320772171 }, { \u0026#34;x\u0026#34;: 0.3871026039123535, \u0026#34;y\u0026#34;: 0.1847274750471115 } ] }, \u0026#34;text\u0026#34;: \u0026#34;29368730 PINE PASS VIT W $2.30\u0026#34; } } ] } Extracting the price of each line item in ruby would look something like:\nline_item_groups.collect do |line_item_group| line_item_group[\u0026#39;line_items\u0026#39;].collect do |line_item| line_item_expense_fields.find do |line_item_expense_field| line_item_expense_field[\u0026#39;type\u0026#39;][\u0026#39;text\u0026#39;] == \u0026#39;PRICE\u0026#39; end end end.flatten.compact As a ruby developer I find that a little ugly. It could be made simpler with some syntactical sugar like .dig but not much.\nWrapping a Textract Expense Document # I\u0026rsquo;ll write a wrapper around all that json data for two reasons:\nAs we\u0026rsquo;ve seen, extracting data from large json objects can get pretty ugly. I\u0026rsquo;d like all that ugliness inside one class not scattered across my application\u0026rsquo;s code. Things change fast. I may not always want to use Textract for reading receipts but receipts seldom change. If a better tool for reading receipts comes along it would helpful to have all the Textract specific logic in one place clearly exposing only the receipt functionality I need from that encapsulated data. What do I want my wrapper to do ? # To sum up. I want my Wrapper to:\nreturn an array of summary fields return an array of Line Item Groups each with a collection of line items. tell me if a field is sensitive field soit can be ignored when sending text to an LLM. As you can see from the sample JSON above a large amount of data is geometry data which I simply don\u0026rsquo;t need so the first thing I will do when receiving Textract output is to remove it before storing it.\nremove all the geometry data Before I create my wrapper I\u0026rsquo;ll create a utility class to encapsulate any summary field or line item values the wrapper returns:\nExpense Value # \u0026ldquo;Marks \u0026amp; Spenser\u0026rdquo; isn\u0026rsquo;t just a word, that word has a context - it\u0026rsquo;s the name of the vendor. Moreover, in giving us this word/context Textract also gives us a number between 0 .0 to1.0 which tells us how confident Textract is that it\u0026rsquo;s right.\nSo, I will create a utility class ExpenseValue that has value, context and confidence attributes. I\u0026rsquo;m guessing as the code evolves we can add other utilities to tell us things that may be useful such as \u0026lsquo;is it a number\u0026rsquo;, \u0026lsquo;does it contain a currency symbol\u0026rsquo;.\nRemember that I don\u0026rsquo;t want to send sensitive data to an LLM ? I can also use this class to tell me if the value may be sensitive from its context e.g. \u0026lsquo;Name\u0026rsquo;\nvalue e.g. Marks \u0026amp; Spenser confidence e.g. 0.94 context - e.g. Vendor Name senstive? - true/false from context. Expense Document # My expense document wrapper will extract data from the json document and pass it to me as Expense Value objects:\n#fields which will return an array of populated ExpenseValue objects that are summary fields. #tables which return an array of line_item_groups, each array will have its own array of ExpenseValue objects that represent the line item of type Expense_Row. Textract kindly breaks rows down into smaller parts like PRICE, TEXT and PRODUCT_CODE but I only need the whole row. #remove_geometry! If you\u0026rsquo;re wondering why I have renamed summary_fields to #fields and line_item_groups to #tables it\u0026rsquo;s because I think they express the intent better. I also don\u0026rsquo;t want to get too locked-in to AWS Textract terminology in case I should choose to move away from it.\nUp Next # In the next article I create the Textract Wrapper - Expense Value class using TDD\n","date":"12 December 2025","externalUrl":null,"permalink":"/articles/tdd-with-rspec-and-claude-3-problem-and-solution-spaces/","section":"","summary":"Writing a tests to remove geometry data from an AWS Textract document","title":"TDD with Rspec \u0026 Claude - Part 3 - Defining the Problem and Solution we want to solve with TDD.","type":"articles"},{"content":" Previously # In the previous article in this series I described both Problem and Solution Space along with requirements for a utility Expense Value class and an Expense Document class that makes use of that utility class.\nBefore Starting - Prepare a Test Fixture # When I first started Rails way back - Rails 2.1 I seem to remember - tests relied on fixtures to run. Fixtures are pre-built data that exist in a separate file to the test being run a simple example would be:\nusers.yml\n- parent: id: 1 parent_id: NULL title: Parent - child: id: 2 parent_id: 1 title: Child I never liked this because you have to read and understand the data in the fixtures in order to understand what the test is doing.\nTypically I use FactoryBot to create mock data that is created within the test file where it is so much easier to read and understand what mock data is important for the test.\nI bring this up only because for this Textract code I am reverting back to testing against a fixture simply because the textract data is so highly structured it would be all too easy to make a mistake creating mock test data and then have production code pass for the wrong reason.\nWhen Textract reads an image it returns a documents collection that I store on aws s3 in one file as a collection of connected documents though in reality it\u0026rsquo;s almost always one document. I downloaded one to use as fixture but this fixture contains an array of documents and all I wanted was a fixture that contains just the first one.\nCopy and pasting such a large number of rows is prone to error so I asked Claude to do the heavy lifting for me:\nuse the fixture spec/fixtures/documents.json to create a file called spec/fixtures/document.json that is the first document in the documents array\nClaude complained about the file size and tokens but still went on to extract 8009 lines of document json straight into my document.json file exactly as I wanted.\nCreate the Expense Value class # We want to create a simple class with three attributes and one method:\nvalue confidence context sensitive? I explain why in the previous section of this series so for now I\u0026rsquo;ll go straight into the TDD.\nPrompt to create the test for the Expense Value class (which does not exist yet) # Create an rspec test file for a class called ExpenseValue. The spec file should have a subject that takes an initializer with three named argumants, value, confidence and context.\nClaude suggested this which went beyond my expectations:\nrequire \u0026#39;rails_helper\u0026#39; RSpec.describe ExpenseValue, type: :model do ##################################################################### # Good: it did what I asked and created an instance of the class being # tested as subject with appropriate named arguments. ##################################################################### describe \u0026#39;#initialize\u0026#39; do subject(:expense_value) do described_class.new( value: value, confidence: confidence, context: context ) end ##################################################################### # Good: Good that the initializer arguments are set as let{} # # Not So Good: # my only criticism is admittedly a bit pedantic in that the test values # are hard coded. For the test to be valid my class doesn\u0026#39;t have to return # the string \u0026#39;Sample Value\u0026#39; or the confidence 0.95, it just has to return # whatever string or number is passed to the initializer. # # Instead of: # let(:value) { \u0026#39;Sample Value\u0026#39; } #. let(:confidence) { 0.95 } # # I would prefer a random value using the Faker gem. # let(:value) { Faker::Lorem.sentence } # let(:confidence) { Faker::Number.within(range: 0..1) } ##################################################################### let(:value) { \u0026#39;Sample Value\u0026#39; } let(:confidence) { 0.95 } let(:context) { \u0026#39;Sample Context\u0026#39; } ##################################################################### # Good: I didn\u0026#39;t ask it to create tests for the attributes that # return values for named arguments but it\u0026#39;s a perfectly reasonable # to expect these tests to be created and they\u0026#39;re fine except maybe # the first one which feels a little unneccessary. ##################################################################### it \u0026#39;initializes with value, confidence, and context\u0026#39; do expect(expense_value).to be_an_instance_of(ExpenseValue) end it \u0026#39;sets the value attribute\u0026#39; do expect(expense_value.value).to eq(value) end it \u0026#39;sets the confidence attribute\u0026#39; do expect(expense_value.confidence).to eq(confidence) end it \u0026#39;sets the context attribute\u0026#39; do expect(expense_value.context).to eq(context) end end end Don\u0026rsquo;t forget, I don\u0026rsquo;t consider the prompt to be my specification. The specification is the sum total of the test descriptions and the test is the code within those descriptions.\nMy griping about using hard coded test values is trivial, the test is still easy to read so I\u0026rsquo;ll accept what claude has created.\nPrompt to create the Expense Value class # Having accepted the description and test code we may as well get the tests passing by writing the Production code.\n\u0026ldquo;Create a class that will pass the all of the tests in spec/models/event_value_spec.rb\u0026rdquo;\nClaude created the class and ran the tests. Everything works.\n########################################################################################### # Great: it\u0026#39;s used the standard ruby idiom attr_reader for simple getter # and it ran the tests to make sure they all pass. ########################################################################################### class ExpenseValue attr_reader :value, :confidence, :context def initialize(value:, confidence:, context:) @value = value @confidence = confidence @context = context end end Prompt to create the Expense Value class sensitive? Method # The initializer and attribute readers are about as simple as it gets but my Solution Space also requires a sensitive? method that tells me if that expense value is considered to be something we don\u0026rsquo;t want to share with an LLM.\nClaude can\u0026rsquo;t possibly know what I consider to be sensitive in our use case so I need to tell it.\nWrite tests in the ExpenseValue spec that returns true if the ExpenseValue#context is in any of the following values \u0026ldquo;NAME\u0026rdquo;, \u0026ldquo;RECEIVER_ADDRESS\u0026rdquo;, \u0026ldquo;RECEIVER_NAME\u0026rdquo;\n################################################################################### # GREAT: in my docs above I referred to the method as sensitive? but forgot to # specify that in my prompt. Claude seems to have understood the fields are # PII (Personally Identifiable Information) and named the method accordingly. # I\u0026#39;ll take that. ################################################################################### ################################################################################### # GOOD: It has created a let(:context){} in each context that gets used in the # subject initializer e.g: # described_class.new( # value: \u0026#39;Test Value\u0026#39;, # confidence: 0.95, # context: context # ) # with a variable for each context e.g: # let(:context) { \u0026#39;NAME\u0026#39; } ################################################################################### describe \u0026#39;#pii?\u0026#39; do subject(:expense_value) do described_class.new( value: \u0026#39;Test Value\u0026#39;, confidence: 0.95, context: context ) end context \u0026#39;when context is NAME\u0026#39; do let(:context) { \u0026#39;NAME\u0026#39; } it \u0026#39;returns true\u0026#39; do expect(expense_value.pii?).to be true end end context \u0026#39;when context is RECEIVER_ADDRESS\u0026#39; do let(:context) { \u0026#39;RECEIVER_ADDRESS\u0026#39; } it \u0026#39;returns true\u0026#39; do expect(expense_value.pii?).to be true end end context \u0026#39;when context is RECEIVER_NAME\u0026#39; do let(:context) { \u0026#39;RECEIVER_NAME\u0026#39; } it \u0026#39;returns true\u0026#39; do expect(expense_value.pii?).to be true end end ################################################################################### # GOOD: Rather than generate random types that are not # NAME or RECEIVER_ADDRESS or RECEIVER_NAME to prove the false case # Claude has picked two other AWS types from the same document.json. ################################################################################### context \u0026#39;when context is not a PII field\u0026#39; do let(:context) { \u0026#39;TOTAL\u0026#39; } it \u0026#39;returns false\u0026#39; do expect(expense_value.pii?).to be false end end context \u0026#39;when context is another non-PII field\u0026#39; do let(:context) { \u0026#39;VENDOR_NAME\u0026#39; } it \u0026#39;returns false\u0026#39; do expect(expense_value.pii?).to be false end end end Claude jumped the gun again by adding the method to ExpenseValue instead of only writing the test but I\u0026rsquo;m not complaining as this is still simple code and Claude has done a good job:\nclass ExpenseValue PII_FIELDS = %w[NAME RECEIVER_ADDRESS RECEIVER_NAME].freeze attr_reader :value, :confidence, :context def initialize(value:, confidence:, context:) @value = value @confidence = confidence @context = context end def pii? PII_FIELDS.include?(context) end end In a nutshell # Good: Claude learned from my previous instruction to use \u0026lsquo;subject\u0026rsquo; as the variable name for the class being tested when it wrote the spec. Good: It used let{} to create the initializer variables rather than hard code them directly into subject creation.. Not so Good: Claude used hard coded test values such as let(:value) { 'Sample Value' } when random values from the Faker gem in the application would have been more appropriate. For the test to be valid the instance being tested doesn\u0026rsquo;t have to return \u0026lsquo;Sample Value\u0026rsquo; it just has to return whatever string is passed to it in the constructor. Good: When it created the Expense Value class claude used the standard ruby idiom of attr_reader to expose these arguments as attributes - I had wondererd if it might try to write its own getter methods. Great: I asked claude to write a test to see if the context attribute matched a number of fields like NAME or RECEIVER_ADDRESS, forgot to give the test a name and claude automatically used the name pii? Having, correctly, assumed this was to identify sensitive personal information. Good: When testing whether the pii? method was working it used a local variable for context e.g. let(:context) { 'NAME' } which kept the tests nicely DRY (though my choice of the attribute \u0026lsquo;context\u0026rsquo; does make things a little less easier to read.) describe \u0026#39;#pii?\u0026#39; do subject(:expense_value) do described_class.new( value: \u0026#39;Test Value\u0026#39;, confidence: 0.95, context: context ##### shared subject code uses context variable ) end context \u0026#39;when context is NAME\u0026#39; do let(:context) { \u0026#39;NAME\u0026#39; } ##### each test sets that context variable it \u0026#39;returns true\u0026#39; do expect(expense_value.pii?).to be true end end context \u0026#39;when context is RECEIVER_ADDRESS\u0026#39; do let(:context) { \u0026#39;RECEIVER_ADDRESS\u0026#39; } ##### each test sets that context variable it \u0026#39;returns true\u0026#39; do expect(expense_value.pii?).to be true end end Good: when testing the pii? method against various context variables it used real options from the fixture such as NAME for a positive result and TOTAL for a negative result rather than abstract strings . This makes the test easier to follow. Up Next # Create the Textract Wrapper class Expense Document and write the test \u0026amp; code for its remove_geometry! method.\n","date":"12 December 2025","externalUrl":null,"permalink":"/articles/tdd-with-rspec-and-claude-4-textract-expense-value/","section":"","summary":"Writing a tests to remove geometry data from an AWS Textract document","title":"TDD with Rspec \u0026 Claude - Part 4 - Textract Expense Value.","type":"articles"},{"content":" Previously # In the previous article we started our TDD adventure with a simple utility class for encapsulating the value and context of a string extracted by AWS Textract Expense so that we don\u0026rsquo;t just get the text value but understand what that value means eg. Name, Address.\nCreate the Expense Document class # Now lets build the Expense Document class. In a nutshell this class wraps 8000+ lines of json code and will enable us to access that data in a clean, DSL kind of way.\nMore details on the why can be found in part 3 of this series.\nPrompt to create the Expense Document spec # Create an rspec test file for a class called ExpenseDocument. The spec file should have a subject that takes an initializer with the data from spec/fixtures/document.json parsed into a hash as the argumant. The argument should be called document and be exposed as a read only attribute\nUnsurprisingly it jumped the gun and created an expense document class too. I\u0026rsquo;m OK with that.\nrequire \u0026#39;rails_helper\u0026#39; RSpec.describe ExpenseDocument, type: :model do let(:fixture_path) { Rails.root.join(\u0026#39;spec/fixtures/document.json\u0026#39;) } let(:document_hash) { JSON.parse(File.read(fixture_path), symbolize_names: true) } describe \u0026#39;#initialize\u0026#39; do subject(:expense_document) do described_class.new(document: document_hash) end ################################################################## # NOT SO GOOD: this test is a little unneccessary, it\u0026#39;s really # testing the test - i.e. creation of the subject. ################################################################## it \u0026#39;initializes with a document argument\u0026#39; do expect(expense_document).to be_an_instance_of(ExpenseDocument) end it \u0026#39;exposes document as a read-only attribute\u0026#39; do expect(expense_document.document).to eq(document_hash) end it \u0026#39;has the document attribute\u0026#39; do expect(expense_document).to respond_to(:document) end it \u0026#39;does not allow setting the document attribute\u0026#39; do expect(expense_document).not_to respond_to(:document=) end end end class ExpenseDocument attr_reader :document def initialize(document:) @document = document end end Prompt to create the remove_geometry method! # This is where we get into actual code and logic not just simple getters/setters. A large part of any TextractDocument is geometry data that describes the location of the block on the receipt that was removed. I don\u0026rsquo;t need this and it\u0026rsquo;s bloating the size of the files I store and use so I want to remove it.\nCreate a test and method ExpenseDocument#remove_geometry! The method must remove every instance of geometry from ExpenseDocument#document created by the fixture spec/fixtures/document.json\ndescribe \u0026#39;#remove_geometry!\u0026#39; do subject(:expense_document) do described_class.new(document: document_hash) end ################################################################ # GREAT: this isn\u0026#39;t any easy thing to test for and I think it does # a great job. It\u0026#39;s created a geometry_count_before that iterates # through the document looking for \u0026#39;geometry\u0026#39; keys. It checks these # keys exist before the method is called and none after. ################################################################ it \u0026#39;removes all geometry keys from the document\u0026#39; do # Verify geometry exists before removal geometry_count_before = count_geometry_keys(expense_document.document) expect(geometry_count_before).to be \u0026gt; 0 expense_document.remove_geometry! # Verify all geometry keys are removed geometry_count_after = count_geometry_keys(expense_document.document) expect(geometry_count_after).to eq(0) end ################################################################ # GOOD: this is a test to ensure that the method returns # self for method chaining. I didn\u0026#39;t ask it to do this though # a similar class in my existing app does the same thing... ################################################################s it \u0026#39;returns self for method chaining\u0026#39; do result = expense_document.remove_geometry! expect(result).to eq(expense_document) end ################################################################### # NOT SO GOOD: these next two test are overkill. Effectively they\u0026#39;re testing # the same thing as the \u0026#39;removes all geometry keys from the document\u0026#39; above # but in a more explcit way. # Normally I would favour a more explicit test but the # \u0026#39;removes all geometry keys from the document\u0026#39; test comprehensively # covers the entire document and there is always a chance that geometry # data exists outside blocks, summary_fields and line_items ################################################################### it \u0026#39;removes geometry from blocks\u0026#39; do expense_document.remove_geometry! expense_document.document[:blocks]\u0026amp;.each do |block| expect(block[:geometry]).to be_nil end end it \u0026#39;removes geometry from nested structures\u0026#39; do expense_document.remove_geometry! # Check summary fields expense_document.document[:summary_fields]\u0026amp;.each do |field| expect(field[:geometry]).to be_nil expect(field.dig(:value_detection, :geometry)).to be_nil expect(field.dig(:label_detection, :geometry)).to be_nil end # Check line item groups expense_document.document[:line_item_groups]\u0026amp;.each do |group| group[:line_items]\u0026amp;.each do |item| item[:line_item_expense_fields]\u0026amp;.each do |field| expect(field[:geometry]).to be_nil expect(field.dig(:value_detection, :geometry)).to be_nil expect(field.dig(:label_detection, :geometry)).to be_nil end end end end ################################################################## # GREAT: it\u0026#39;s created a method to iterate through every key in the # hash counting geometry keys ################################################################## def count_geometry_keys(obj, count = 0) case obj when Hash count += 1 if obj.key?(:geometry) obj.each_value { |v| count = count_geometry_keys(v, count) } when Array obj.each { |item| count = count_geometry_keys(item, count) } end count end end But wait, I spotted a problem\u0026hellip; # The count_geometry_keys method is looking for a key :geometry and I\u0026rsquo;m sure my Textract hash has string keys.\ndef count_geometry_keys(obj, count = 0) case obj when Hash count += 1 if obj.key?(:geometry) obj.each_value { |v| count = count_geometry_keys(v, count) } when Array obj.each { |item| count = count_geometry_keys(item, count) } end count end The problem starts here where the test chooses to symbolize_names: true not sure why it did that. It makes the code very brittle as every time a document object gets passed to ExpenseDocument the caller has to remember to symbolize the keys.\nRSpec.describe ExpenseDocument, type: :model do let(:fixture_path) { Rails.root.join(\u0026#39;spec/fixtures/document.json\u0026#39;) } let(:document_hash) { JSON.parse(File.read(fixture_path), symbolize_names: true) } I could:\nChange the code so that it doesn\u0026rsquo;t symbolize_names Set the document as a Rails HashWithIndifferentAccess which doesn\u0026rsquo;t care whether the key is a string of a symbol. Option 2 is the more robust so I will tell Claude to make the change - TBH normally I would do this by hand by I\u0026rsquo;m testing how much Claude can do.\nPrompt to fix by telling Claude to use the HashWithIndifferentAccess # Change the assignment of the document argument in the ExpenseDocument initializer so that the document is a HashWithIndifferentAccess\n################################################################################ # GOOD: I\u0026#39;m glad I asked Claude to do this rather than make the change by hand # I was unaware that Hash in Rails has the #with_indifferent_access method. # I would have created a less elegant # # @document = HashWithIndifferentAccess.new(document) ################################################################################ def initialize(document:) @document = document.with_indifferent_access end It also upated the specs. TBH the second test is a bit irrelevant as it\u0026rsquo;s basically testing the HashWithIndifferentAccess class which isn\u0026rsquo;t part of our code. I\u0026rsquo;ll leave it in but it could easily be deleted.\nit \u0026#39;exposes document as a read-only attribute\u0026#39; do expect(expense_document.document).to be_a(ActiveSupport::HashWithIndifferentAccess) end it \u0026#39;allows accessing document keys with both symbols and strings\u0026#39; do expect(expense_document.document[:blocks]).to eq(expense_document.document[\u0026#39;blocks\u0026#39;]) end In a nutshell # NOT SO GOOD: Claude created a test that was effectively testing its own test code. GREAT: Claude impressed me with a test that ensured there were geometry objects present in the hash, ran the remove_geometry! method then ensured there were none using the same code. GREAT: It created a utility method that iterated through all the keys in the hash counting the number of geometry objects and re-used this - see above. GOOD: it created a separate test to ensure the return value from the method was the subject. By making this separate Claude ensured a single test only tested for one thing. NOT SO GOOD: it created a couple of tests that, logically, were testing the same thing as a previous test. GOOD: I spotted a flaw where Claude expected the test hash to have symbolic keys when the reality is they are likely to be strings. When prompted to fix this using a HasWithIndifferentAccess it used a neat solution that I didn\u0026rsquo;t know existed. Up Next # I used Claude \u0026amp; TDD to create tests and methods to implement the #tables and #fields methods.\n","date":"12 December 2025","externalUrl":null,"permalink":"/articles/tdd-with-rspec-and-claude-5-textract-expense-document/","section":"","summary":"Writing a tests to remove geometry data from an AWS Textract document","title":"TDD with Rspec \u0026 Claude - Part 5 - Textract Expense Document that removes geometry.","type":"articles"},{"content":"I need to extract fields (summary_fields) and tables (line_item_groups) each with an array of line_items\nSummary Fields to Fields # Summary Fields are blocks of text such as Vendor name or address. A typical summary field looks like this:\n{ \u0026#34;page_number\u0026#34;: 1, \u0026#34;type\u0026#34;: { \u0026#34;confidence\u0026#34;: 99.87956237792969, \u0026#34;text\u0026#34;: \u0026#34;VENDOR_ADDRESS\u0026#34; }, \u0026#34;value_detection\u0026#34;: { \u0026#34;confidence\u0026#34;: 99.7436294555664, \u0026#34;text\u0026#34;: \u0026#34;Manchester Terminal 2\\nManchester Airport\\nManchester\\nM90 1QX\u0026#34; } } and is one element of the summary_fields array.\nPrompt to extract summary_fields as the fields method # Create tests and a method ExpenseDocument#fields that returns an array of ExpenseValue objects. The ExpenseValue Objects are created from summary_fields with the value being the value_detection.text, the context being the type.text and the confidence being the value_detection_context.\ndescribe \u0026#39;#fields\u0026#39; do subject(:expense_document) do described_class.new(document: document_hash) end it \u0026#39;returns an array\u0026#39; do expect(expense_document.fields).to be_an(Array) end ################################################################## # GOOD: Never seen the all(be_an(ExpenseValue)) before - I like it ################################################################## it \u0026#39;returns an array of ExpenseValue objects\u0026#39; do expect(expense_document.fields).to all(be_an(ExpenseValue)) end it \u0026#39;creates ExpenseValue objects from summary_fields\u0026#39; do fields = expense_document.fields expect(fields.size).to eq(document_hash[:summary_fields].size) end it \u0026#39;sets the value from value_detection.text\u0026#39; do first_field = expense_document.fields.first expected_value = document_hash[:summary_fields].first.dig(:value_detection, :text) expect(first_field.value).to eq(expected_value) end it \u0026#39;sets the context from type.text\u0026#39; do first_field = expense_document.fields.first expected_context = document_hash[:summary_fields].first.dig(:type, :text) expect(first_field.context).to eq(expected_context) end it \u0026#39;sets the confidence from value_detection.confidence\u0026#39; do first_field = expense_document.fields.first expected_confidence = document_hash[:summary_fields].first.dig(:value_detection, :confidence) expect(first_field.confidence).to eq(expected_confidence) end it \u0026#39;creates correct ExpenseValue objects for all summary_fields\u0026#39; do fields = expense_document.fields fields.each_with_index do |field, index| summary_field = document_hash[:summary_fields][index] expect(field.value).to eq(summary_field.dig(:value_detection, :text)) expect(field.context).to eq(summary_field.dig(:type, :text)) expect(field.confidence).to eq(summary_field.dig(:value_detection, :confidence)) end end end ################################################################### # GOOD: nice bit of defensive coding: # return [] unless @document[:summary_fields] #################################################################### def fields return [] unless @document[:summary_fields] @document[:summary_fields].map do |summary_field| ExpenseValue.new( value: summary_field.dig(:value_detection, :text), confidence: summary_field.dig(:value_detection, :confidence), context: summary_field.dig(:type, :text) ) end end Prompt to extract tables # Create tests and a method ExpenseDocument#tables that returns an array of line_item_groups. Each line item group returns an array of ExpenseValue objects. The ExpenseValue Objects are created from line_items with the type.text \u0026ldquo;EXPENSE_ROW\u0026rdquo;. The value being the value_detection.text, the context being the type.text and the confidence being the value_detection_context.\ndescribe \u0026#39;#tables\u0026#39; do subject(:expense_document) do described_class.new(document: document_hash) end it \u0026#39;returns an array\u0026#39; do expect(expense_document.tables).to be_an(Array) end it \u0026#39;returns an array of arrays\u0026#39; do expect(expense_document.tables).to all(be_an(Array)) end it \u0026#39;returns one array per line_item_group\u0026#39; do expect(expense_document.tables.size).to eq(document_hash[:line_item_groups].size) end it \u0026#39;each array contains ExpenseValue objects\u0026#39; do expense_document.tables.each do |table| expect(table).to all(be_an(ExpenseValue)) end end it \u0026#39;creates ExpenseValue objects only from EXPENSE_ROW items\u0026#39; do first_table = expense_document.tables.first first_group = document_hash[:line_item_groups].first expense_row_count = first_group[:line_items].count do |item| item[:line_item_expense_fields].any? { |field| field.dig(:type, :text) == \u0026#39;EXPENSE_ROW\u0026#39; } end expect(first_table.size).to eq(expense_row_count) end it \u0026#39;sets the value from EXPENSE_ROW value_detection.text\u0026#39; do first_table = expense_document.tables.first first_expense_value = first_table.first first_group = document_hash[:line_item_groups].first first_line_item = first_group[:line_items].first expense_row = first_line_item[:line_item_expense_fields].find do |field| field.dig(:type, :text) == \u0026#39;EXPENSE_ROW\u0026#39; end expect(first_expense_value.value).to eq(expense_row.dig(:value_detection, :text)) end it \u0026#39;sets the context from EXPENSE_ROW type.text\u0026#39; do first_table = expense_document.tables.first first_expense_value = first_table.first expect(first_expense_value.context).to eq(\u0026#39;EXPENSE_ROW\u0026#39;) end it \u0026#39;sets the confidence from EXPENSE_ROW value_detection.confidence\u0026#39; do first_table = expense_document.tables.first first_expense_value = first_table.first first_group = document_hash[:line_item_groups].first first_line_item = first_group[:line_items].first expense_row = first_line_item[:line_item_expense_fields].find do |field| field.dig(:type, :text) == \u0026#39;EXPENSE_ROW\u0026#39; end expect(first_expense_value.confidence).to eq(expense_row.dig(:value_detection, :confidence)) end it \u0026#39;creates correct ExpenseValue objects for all line items\u0026#39; do expense_document.tables.each_with_index do |table, group_index| line_item_group = document_hash[:line_item_groups][group_index] table.each_with_index do |expense_value, item_index| line_item = line_item_group[:line_items][item_index] expense_row = line_item[:line_item_expense_fields].find do |field| field.dig(:type, :text) == \u0026#39;EXPENSE_ROW\u0026#39; end expect(expense_value.value).to eq(expense_row.dig(:value_detection, :text)) expect(expense_value.context).to eq(\u0026#39;EXPENSE_ROW\u0026#39;) expect(expense_value.confidence).to eq(expense_row.dig(:value_detection, :confidence)) end end end end def tables return [] unless @document[:line_item_groups] ############################################################################ # GOOD ENOUGH: this is a little tricker than #fields as the code has to loop # through line_item_groups then line_items but only creating ExpenseValue objects # where the line_item is of type \u0026#39;EXPENSE_ROW\u0026#39; # if it were me I probably would have split this into two methods but # this works, happy to keep it. ############################################################################ @document[:line_item_groups].map do |line_item_group| line_item_group[:line_items].map do |line_item| expense_row = line_item[:line_item_expense_fields].find do |field| field.dig(:type, :text) == \u0026#39;EXPENSE_ROW\u0026#39; end next unless expense_row ExpenseValue.new( value: expense_row.dig(:value_detection, :text), confidence: expense_row.dig(:value_detection, :confidence), context: expense_row.dig(:type, :text) ) end.compact end end In a nutshell # GOOD: I learned some neat new syntax for ensuring all objects in a collection are of a specific type GOOD: Claude put in some nice defensive coding in specific array in the document didn\u0026rsquo;t exist. GOOD ENOUGH: Claude created some code that was a little verbose - I had to read it a few times before understanding it but it was acceptable. Thats a Wrap ! # I hope you found this interesting. Most of all, if you\u0026rsquo;ve been hesitant on getting started with AI coding I hope this gives you a framework and enough confidence to get started.\nIf there\u0026rsquo;s something you think I\u0026rsquo;ve missed in the articles let me know and I will do my best to add it.\n","date":"12 December 2025","externalUrl":null,"permalink":"/articles/tdd-with-rspec-and-claude-6-textract-summary-fields-and-line-items/","section":"","summary":"Writing a tests to remove geometry data from an AWS Textract document","title":"TDD with Rspec \u0026 Claude - Part 6 - Textract Expense Document that returns data.","type":"articles"},{"content":"","externalUrl":null,"permalink":"/contact-us/","section":"Digital Donkeys","summary":"","title":"","type":"page"},{"content":"","externalUrl":null,"permalink":"/one-thing-partners/","section":"Digital Donkeys","summary":"","title":"","type":"page"},{"content":" Services # ","externalUrl":null,"permalink":"/services/","section":"","summary":"","title":"","type":"services"},{"content":"Digital Donkeys started in 2023 when we began to work on a number of HR and Payroll automations.\nWe realised that working directly with people, helping them understand how technology can benefit them and their businesses was something we not only enjoyed but we were pretty good at it.\nSince that time AI has emerged, massively increasing the scope of things that tech can do for any business whilst also confusing us all about what actually does work and where best to start.\nIn all that noise and clutter of shiny promises it can be so easy to lose sight of the prize - Return on Investment.\nThis is where we see our honesty, experience and expertise adding real value to your organisation and would love to be your trusted partner on that exciting, often daunting, journey into the future.\nWe chose the name Digital Donkeys because our focus is on things that work over things that try to impress.\nContact us for a chat. We would love to hear from you.\n","externalUrl":null,"permalink":"/about-us/","section":"Digital Donkeys","summary":"","title":"About Us","type":"page"},{"content":"","externalUrl":null,"permalink":"/categories/","section":"Categories","summary":"","title":"Categories","type":"categories"}]