An SDET interview checks two things at once: can you write real code, and do you think like a tester while you do it. Expect a short live problem on strings or collections, questions on how you would design and run a framework, how you keep a suite fast and trustworthy in CI, and how you would plan tests for a feature nobody has built yet. There are also stories about working with developers. Each question shows what the interviewer is listening for, a shape for your answer and a sample you could say out loud. Replace the stories with your own.
Search all questions by round, difficulty and level, or save the ones you want to practise.
Path: the short version of how you arrived here, one or two steps.
The difference: an SDET builds the tools, frameworks and checks that let a team test fast, and still thinks like a tester.
Why it fits you: the part of that mix you enjoy most.
"I started as a manual tester on a web product, and within a few months I was tired of running the same regression by hand every release. I taught myself enough scripting to automate the login and checkout checks, and that became the thing I liked most. The way I'd put the difference is this: a manual tester finds problems by exploring, a developer builds the feature, and an SDET builds the code that lets the whole team find problems quickly and repeatably. So I write production-quality code, but the question I'm always asking is how this could break and how we'd know. I like sitting in that middle spot, because I get to design things and still be the person who spots the missing edge case."
Describing the job as just converting manual test cases into scripts, with no mention of design, code quality or thinking about risk.
What pulls you: the part of quality work you genuinely enjoy.
What you build: frameworks, tooling and pipelines are real engineering.
Honest ambition: where you want to grow inside this path.
"Honestly, it's because the problems I find most interesting are the ones about how software fails. When I build a feature, I enjoy it, but when I build a check that catches a whole class of bugs for every team, that feels bigger. The engineering is real too. I've designed a framework, sped up a pipeline and written tooling that other people depend on daily, so I don't feel I'm giving up coding. And I like the breadth. I get to read code across the whole system instead of one corner of it. Where I want to grow is toward test architecture, deciding how a whole product gets tested, and that's a path that only exists on this side."
Saying testing is easier to get into and you plan to switch to development after a year.
Layers: unit, API or service, UI, and anything else, with rough proportions.
Where it ran: on each pull request, nightly, before release.
Your part: what you designed or changed, and why.
"On my last project we had a web app backed by about a dozen services. Developers owned the unit tests, and there were a lot of those. I owned the layer above: API tests for each service, a small set of contract tests between the two busiest services, and around sixty UI tests for the main user journeys. The unit and API tests ran on every pull request in a few minutes. The UI suite ran on merge to main and again nightly across two browsers. The part I built myself was the API test layer, including a test data builder so each test created its own users and orders, and a report that posted failures straight into the team channel with a link to the logs."
Listing tool names without saying what the tests covered, when they ran, or what you personally did.
At work: reading product code, reviewing pull requests, building real tooling.
Outside the day job: practice problems, small side projects, reading.
Proof: one recent thing you learned and used.
"Most of it happens inside the job if I set it up right. I read the product code for the features I test, not just the tickets, and I review developers' pull requests, which teaches me patterns and shows me where bugs are likely. I also try to build real tooling rather than scripts, with tests and proper structure, because test code deserves the same standard. Outside of that, I do a couple of coding problems a week to keep my data structures sharp, and I pick one small project every few months. Recently I learned enough about concurrency to rewrite our test data setup so it runs in parallel safely, and that came straight from reading the product's own threading code."
Having no concrete habits, or saying you only learn when a project forces you to.
Clarify: extra spaces, empty input, punctuation, null.
Solve: split on runs of whitespace, walk from the end, join with single spaces.
Test it: normal, single word, empty, leading and trailing spaces, several spaces between words.
"First I'd ask whether extra spaces should be kept or collapsed. Assuming collapsed, I trim the string, split it on one or more whitespace characters, then build the result by walking the array from the last word to the first and putting a single space between words. That's linear in the length of the string. For tests, I'd start with the normal case from your example, then a single word, which should come back unchanged. Then an empty string and a string of only spaces, which should both give an empty string. Then leading, trailing and doubled spaces, to prove they collapse. And I'd ask what should happen with null, because I'd rather decide that on purpose than get a surprise exception."
static String reverseWords(String s) {
String[] parts = s.trim().split("\\s+");
StringBuilder out = new StringBuilder();
for (int i = parts.length - 1; i >= 0; i--) {
out.append(parts[i]);
if (i > 0) out.append(' ');
}
return out.toString();
}
Writing the code and stopping, with no test cases, or never asking how extra spaces should behave.
Count: one pass to count each character, in a map that remembers insertion order.
Find: a second pass to return the first with a count of one.
Edge cases: no unique character, empty string, case sensitivity.
"I'd do two passes. In the first, I count each character using a LinkedHashMap, because it keeps the order characters were first seen. In the second, I go through the map's entries and return the first one with a count of one. If none has a count of one, I return null, or whatever the caller agrees on. Both passes are linear, so it's O(n) time, and the extra space is bounded by the number of distinct characters. A plain HashMap would also work if I walked the original string in the second pass instead of the map. Before coding it, I'd ask whether upper and lower case count as the same letter, because that changes the answer for a word like 'Level'."
static Character firstUnique(String s) {
Map<Character, Integer> counts = new LinkedHashMap<>();
for (char c : s.toCharArray()) counts.merge(c, 1, Integer::sum);
for (Map.Entry<Character, Integer> e : counts.entrySet()) {
if (e.getValue() == 1) return e.getKey();
}
return null;
}
A nested loop comparing every character with every other one, presented as fine without mentioning it is O(n squared).
Sets: expected minus actual is missing, actual minus expected is unexpected.
Duplicates: a counter on the actual list, because a set hides them.
Useful output: sorted, separate lists so the failure message reads clearly.
"I'd turn both lists into sets. Expected minus actual gives the missing IDs, and actual minus expected gives the ones that shouldn't be there. Each of those is a linear operation, so it scales fine for big lists. But a set silently drops duplicates, and a duplicate order is often the real bug, so I'd count the actual list separately and report any ID that appears more than once. I'd sort each list so the output is stable between runs, which matters when you compare two failure reports. In the test itself, I'd put all three lists into the assertion message, so when it fails in CI, whoever reads it sees exactly what went wrong without rerunning anything."
from collections import Counter
def compare_ids(expected, actual):
exp, act = set(expected), set(actual)
missing = sorted(exp - act)
unexpected = sorted(act - exp)
duplicates = sorted(i for i, n in Counter(actual).items() if n > 1)
return missing, unexpected, duplicates
Comparing with a set only and never noticing that duplicates disappear.
Parse: split each line, skip anything that doesn't match the format.
Count: a counter keyed by test name, only for failures.
Rank: take the top three, and say how ties are handled.
"I'd read the lines one at a time, split each on whitespace, and only count lines where the first field is FAIL and there's a test name after it. Anything malformed I skip rather than crash on, because real logs always have junk lines in them. I'd count failures per test with a Counter, then call most_common with three. That's a single pass over the lines, and ranking a small number of distinct tests is cheap. For ties, Counter keeps the order it first met each name, so I'd mention that and ask if we want something else, like alphabetical. In practice I'd also count passes, because a test that failed five times out of five is a different problem from one that failed five times out of fifty."
from collections import Counter
def worst_offenders(lines, top=3):
fails = Counter()
for line in lines:
parts = line.split()
if len(parts) >= 2 and parts[0] == "FAIL":
fails[parts[1]] += 1
return fails.most_common(top)
Code that crashes on a blank or malformed line, or sorting the whole list by hand when a counter does it cleanly.
Approach: push openers onto a stack, pop and match on each closer.
Fail fast: a closer with an empty stack, or a mismatched pair.
Finish: balanced only if the stack is empty at the end.
Tests: cases that break naive counting solutions.
"I'd use a stack. For each character, if it's an opening bracket I push it. If it's a closing bracket, I check the stack isn't empty, pop the top, and make sure it's the matching opener. Other characters I ignore. At the end the string is balanced only if the stack is empty. It's one pass, so linear time. For tests, I'd pick cases that catch the common mistakes. An empty string should be balanced. A closer first, like a close bracket then an open one, should fail even though the counts match. Crossed pairs, like open round, open square, close round, close square, should fail too, which catches anyone who just counts. And a string with only openers should fail, which catches anyone who forgets the final empty check."
static boolean balanced(String s) {
Deque<Character> stack = new ArrayDeque<>();
for (char c : s.toCharArray()) {
if (c == '(' || c == '[' || c == '{') stack.push(c);
else if (c == ')' || c == ']' || c == '}') {
if (stack.isEmpty()) return false;
char open = stack.pop();
if ((c == ')' && open != '(') || (c == ']' && open != '[')
|| (c == '}' && open != '{')) return false;
}
}
return stack.isEmpty();
}
Counting opens and closes and calling it done, which passes 'close then open' as balanced.
The idea: many fast unit tests, fewer service tests, a thin layer of end-to-end tests.
Why: lower layers are faster, cheaper and point straight at the fault.
The cone: mostly UI and manual checks on top, slow feedback, lots of flakiness.
"The pyramid says most of your tests should be small and fast, like unit tests that run in milliseconds and tell you exactly which function broke. Above that, fewer tests at the service or API level check that pieces work together. At the top, a thin layer of end-to-end tests proves the key journeys work for a real user. The shape matters because cost goes up as you climb. UI tests are slower, break for reasons unrelated to the bug, and when they fail you still have to dig to find the cause. The upside-down cone is a team where nearly everything is checked through the UI or by hand. You can spot it quickly: the suite takes hours, people rerun failures until they go green, and bugs are found days after the code was written. I treat the pyramid as a guide, not a rule, but that cone is always a warning sign."
Saying end-to-end tests are always best because they test what the user does, with no word on speed or cost.
Risks first: paying twice, not paying, paying the wrong amount, time zones, cancellation.
Layers: unit for date and rule logic, service tests for the scheduler, a few end-to-end journeys.
Testability asks: a controllable clock, a way to trigger the scheduler, idempotent payment calls.
Beyond function: failure and retry, audit, access control, and monitoring after launch.
"I'd start with what hurts most if it goes wrong. For scheduled payments that's paying twice, not paying at all, paying the wrong amount, and anything around dates: time zones, month ends, leap days, and daylight saving changes. Then cancelling or editing a payment just before it runs, and what happens if the balance is too low on the day. Next I'd spread the checks. Date and validation rules get unit tests, because there are a lot of combinations. The scheduler gets service-level tests, including what happens when the payment provider times out and whether a retry could charge twice. Then just a few end-to-end journeys: schedule, edit, cancel, and see it run. The big thing I'd ask for now, while it's still a design, is a way to control the clock in test environments and trigger the scheduler on demand. Without that, we'd be waiting for real dates to pass."
Jumping straight to UI test cases for the form, with nothing on dates, duplicates, failures or testability.
Assess risk: how critical is it, and what could break later without tests.
Do the vital minimum: automate the highest-risk checks in the time you have.
Make the gap visible: a ticket with a date, agreed with the team.
Fix the cause: plan automation alongside development next sprint.
"I wouldn't block the release on principle, but I also wouldn't let it slip by quietly. First I'd look at the risk. If this feature touches money, security or data, I'd push harder. Then I'd use the two days for the highest-value checks, usually a couple of API tests for the core rule and one for the main error case, because those are quick to write and protect against the most likely regression. Whatever I can't cover, I'd write down as a ticket for the next sprint, agreed with the product owner, so the gap is a decision everyone can see and not a surprise later. And in the retro I'd raise why automation came last, and suggest writing the tests alongside the code, so we're not in this spot again."
Either refusing to ship until every test exists, or shipping with no tests and saying nothing.
Find the cause: usually one class per page even when pages share widgets.
Split by component: header, search box, data table as their own small objects.
Compose: pages hold components; flows that span pages move to a separate layer.
Migrate safely: move one area at a time, with the suite green throughout.
"When page objects get that big, the cause is usually that every page was modelled as one class, even though pages share parts like a header, a search box or a data table. So the same locators and methods end up copied everywhere. I'd split them into component objects, one for each reusable widget, and have page objects hold those components instead of re-implementing them. Multi-step journeys, like signing up and placing a first order, I'd move out into a separate layer of flows or tasks, so page objects only describe what's on the screen. I'd also make sure page objects don't hold assertions, so they stay reusable across tests. And I wouldn't do it as one big rewrite. I'd pick the noisiest area, refactor it with the suite running green, and let the pattern spread from there."
Proposing a full rewrite of the framework in one go, or not seeing that shared widgets are the real problem.
Own your data: each test creates what it needs, ideally through an API, not the UI.
Make it unique: generated names or IDs so parallel tests never collide.
Clean up or don't need to: tear down, or use data that can safely pile up.
Keep reference data separate: read-only fixtures that no test changes.
"My rule is that every test creates the data it depends on and never assumes something is already there. I'd build a small set of data builders that call the service APIs or a seeding endpoint to create, say, a user with a paid order, because doing that through the UI is slow and fragile. Each record gets a unique value, often a short random suffix on the email or name, so two tests running in parallel can't grab the same account. Anything truly shared, like a list of countries or product categories, becomes read-only reference data that no test is allowed to change. For cleanup, I prefer deleting what the test created in teardown, but in some environments it's safer to let data build up and wipe the environment on a schedule. The one thing I avoid is a shared 'test user one' that every test logs in as."
Relying on a shared spreadsheet of fixed test accounts, or on tests running in a set order.
The summary: passed, failed, skipped, and what changed since the last run.
Each failure: the test name in plain words, the assertion message, and the step it died on.
Evidence: logs, screenshots or request and response, linked straight from the failure.
Context: build, commit, environment and browser or device.
"I think of the reader as a developer who didn't write the test and has two minutes. At the top they need the headline: how many passed, failed and were skipped, and ideally which failures are new since the last run, because that's where to look first. For each failure they need a readable test name, the actual assertion message, like 'expected status shipped but got pending', and the step it failed on. Then evidence one click away, so logs, a screenshot for UI tests, or the request and response for API tests. And the context: which build, which commit, which environment. If a report makes someone rerun the test locally just to understand the failure, it isn't doing its job."
Treating the report as a pass or fail count only, with failures that just say 'assertion failed'.
Layers: tests, then flows or domain steps, then clients (UI, API, database), then config and utilities.
Share the middle: one data builder and one domain model used by every test type.
Config: environments, secrets and settings outside the code.
Around it: reporting, parallel runs, coding standards and review.
"I'd layer it like a normal application. At the bottom, configuration: environment URLs, credentials pulled from a secret store, timeouts, all outside the code. Above that, thin clients: an API client, a UI driver wrapper with page and component objects, and a small database helper that only runs read queries for checks. Then a domain layer, which is where the real reuse happens. Things like 'create a customer with an active subscription' are written once, usually on top of the API client, and used by UI, API and data tests alike. The tests sit on top and read like plain steps with clear assertions. Around all of it I'd set up reporting, tagging so we can pick smoke or regression, and support for running in parallel from day one. And because several people will contribute, I'd add linting and code review for test code, the same as for product code."
Jumping straight to a tool list, or putting locators, data and assertions all inside the test methods.
The problem: what is actually hurting today, and would the new tool fix it.
Trial: port a handful of the hardest tests and compare.
Cost: migration time, skills, integrations, running both for a while.
Decision: a written recommendation with a plan either way.
"I'd start by asking what problem we're trying to solve. If our pain is flaky waits, slow runs or hard debugging, a newer tool might genuinely help. If the pain is bad test design or shared data, a new tool won't fix it and we'd just move the mess. Then I'd run a short, time-boxed trial: take five of our hardest tests, like the ones with iframes, file uploads or multiple tabs, and build them in the new tool alongside the old ones in CI. I'd compare run time, stability, how easy failures are to debug, and how well it fits our languages and pipeline. Then I'd write up the migration cost honestly, including running both for a while. If we switch, I'd move new tests first and old ones area by area, not as one big bang."
Picking a side instantly based on personal preference, or planning to rewrite the whole suite in one go.
Pull request: fast and reliable checks only, like unit, API and a small smoke set.
After merge or nightly: the broader regression, more browsers or devices.
Before release: slow, costly or environment-heavy checks.
The rule: anything in the pull request gate must be fast and almost never flaky.
"I decide by two questions: how fast is it, and how much do I trust it. On every pull request I want feedback in minutes, so that's unit tests, API tests for the touched services, contract checks, and a small smoke set of UI journeys, only tests that are fast and very stable, because a flaky gate teaches people to ignore it. After merge, or nightly, the full regression runs, across more browsers or devices, and failures there get triaged the next morning. Before release, I'd add the slow or expensive things, like long end-to-end journeys, upgrade and migration checks, or anything that needs a production-like environment. I also try to use tags so a change to the payments service can pull in the payments suite on its pull request, even if it's normally a nightly job."
Running the entire suite on every pull request regardless of how long it takes or how flaky it is.
In the code: static fields, singletons, shared driver or client objects.
In the data: the same account, cart or record used by two tests.
In the environment: files, ports, feature flags or config changed by one test.
Prove it: run the suspect pair together, repeatedly, and see which combination breaks.
"I'd look in three places. First, the test code itself: static variables, singletons, or one driver or HTTP client object shared across threads. If a test stores its session token in a static field, two threads will overwrite each other. The fix is to keep that state per test or per thread. Second, the data: two tests logging in as the same user, adding to the same cart, or asserting a count of records that another test is changing. Third, the environment: a test that toggles a feature flag, writes to a fixed file path or changes global config affects every other test running at that moment. To find which one it is, I'd look at which tests fail together, then run small groups side by side many times until I can reproduce it with just two tests. After that, the cause is usually obvious."
Turning parallel off, or adding waits, without finding what the tests share.
Measure: time per test and per stage, including setup, queueing and environment spin-up.
Cut waste: log in and create data through the API, remove fixed sleeps, drop duplicate checks.
Split the work: run tests in parallel across several agents, balanced by past run time.
Move checks: keep a small smoke set in the gate and push the rest after merge or down a layer.
"I'd start by measuring, because the slow part is often not where people think. I'd pull timings per test and per stage, including how long we wait for an agent or for the environment to start. Usually a handful of tests take most of the time, and a lot of it is setup, like logging in through the UI before every test or fixed sleeps. So the quick wins are logging in and creating data through the API and replacing sleeps with proper waits. Then I'd split the suite across several agents in parallel, grouping tests by their past run time so every group finishes around the same moment. Last, I'd look at what really needs to block a merge. Some UI checks can move down to API tests, and the rest can run after merge or nightly. I'd share before and after numbers with the team at the end of the week."
Deleting slow tests without checking what they cover, or asking for bigger machines before knowing where the time goes.
Gather evidence: failure history, logs, screenshots, timing, which agent it ran on.
Reproduce: loop the test many times, locally and in CI, with more logging.
Classify: timing, shared data, order dependence, environment, or a real product race.
Fix and prove: change one thing, rerun the loop, then decide what the fix really was.
"First I'd collect the failures, not just the latest one. Do they all fail on the same step? Same agent, same time of day, same neighbour tests? That pattern usually narrows it a lot. Then I'd try to reproduce it by running just that test in a loop, a hundred times or more, with extra logging around the failing step. If it never fails alone but fails in the full suite, I'm looking at shared data or test order. If it fails alone, it's usually timing, like waiting for a fixed time instead of waiting for a condition, or a real race in the product. That last case matters, because sometimes the test is right and the app really does something wrong one time in twenty, and a retry would hide a genuine bug from users. Once I think I've fixed it, I rerun the same loop to prove the failure is gone."
Adding a retry or a longer sleep and calling it fixed, without knowing why it failed.
Starting point: how bad it was and how the team behaved around it.
Triage: how you decided keep, fix or delete.
Trust: what made people start believing red builds again.
Result: how the team's behaviour changed.
"I joined a team where the UI suite had around four hundred tests and a red build was so normal that people merged over it. I started by pulling three months of results and sorting tests by how often they failed without a matching bug. About a quarter had never caught anything and failed regularly, and many of them checked things the API tests already covered. I deleted those, which was uncomfortable but honest. Another group was flaky for one shared reason, a login helper using a fixed sleep, so fixing one helper steadied dozens of tests. The rest I kept. Then I set a rule that any new flaky test was quarantined within a day with a ticket. After a few weeks of green meaning green, developers started treating a red build as their problem again, which was the whole point."
Rewriting everything from scratch without measuring which tests had value, or never deleting anything.
How it works: the consumer records what it sends and needs back; the provider verifies it can meet that.
Why it helps: each team gets fast, independent feedback before deploying.
Limits: it checks the shape and agreed behaviour, not full business logic or the real environment.
"In a consumer-driven contract setup, the calling service writes tests against a mock of the provider, and those tests record a contract: this request, with these fields, should get back a response of this shape. The contract is shared, often through a broker, and the provider's pipeline replays each request against the real provider code to prove it can still meet every consumer's expectations. So if the provider renames a field that a consumer uses, their own build fails before they deploy, instead of the shared end-to-end suite failing days later. Each team can release on its own schedule with more confidence. The limits are worth saying out loud. A contract checks the interface, not whether the provider's business logic is right, and it doesn't catch config, network or data problems in a real environment. So I'd still keep a few end-to-end tests for the critical journeys, just far fewer."
Describing contract tests as just schema validation, or claiming they remove the need for any integration testing.
Stub: returns canned answers so the code under test can run.
Mock: set up with expectations, and the test checks it was called correctly.
Fake: a working lightweight version, like an in-memory database.
Spy: records how it was called, often wrapping the real object.
"They're all test doubles, things that stand in for a real dependency. A stub just gives back canned answers, like a payment gateway that always says approved, so I can test what my code does next. A mock is about behaviour: I check it was called, how many times and with what arguments, like verifying a receipt email was sent once for the right order. A fake actually works, just in a simpler way, like an in-memory repository instead of a real database. A spy records the calls made to it, and often wraps the real object so the real code still runs. In practice I stub what my code reads from, and mock only the outgoing calls that are the point of the test. The trap is mocking everything, because then the test proves your code talks to your mocks, and a real integration can still be broken."
from unittest.mock import Mock
def test_receipt_sent_after_payment():
gateway = Mock()
gateway.charge.return_value = {"status": "ok"} # stubbed answer
mailer = Mock()
checkout = Checkout(gateway, mailer) # class under test
checkout.pay(order_id=42, amount=100)
mailer.send_receipt.assert_called_once_with(42) # mock check
Using the four words as if they mean the same thing, or mocking the very class under test.
The serious bug: the caught assertion means this test can never fail.
Fragility: fixed sleep, hard-coded URL and credentials, a weak page-source check.
Readability: a name that says nothing, and no page object.
Tone: lead with the bug, explain why, suggest the fix.
"The first thing I'd flag, and it's the serious one, is the try-catch around the assertion. It catches the AssertionError and just prints a line, so this test passes even when login fails. It can never go red, which is worse than having no test. After that, the five-second sleep makes it slow when the app is fast and flaky when the app is slow, so it should wait for something specific, like the dashboard heading. The URL and the password are hard-coded, so they belong in config and a secret store. Checking that the page source contains 'Welcome' is weak, because that word could be anywhere. And test1 tells nobody what it checks. My rewrite would be called something like validUserSeesDashboard, log in through the login page object, wait for the dashboard, and assert the user's name is shown. In the review I'd lead with the swallowed assertion and explain why it matters."
// Submitted for review: find what's wrong with this test
@Test
public void test1() throws Exception {
driver.get("https://staging.example.com/login");
driver.findElement(By.id("user")).sendKeys("admin");
driver.findElement(By.id("pass")).sendKeys("Admin@123");
driver.findElement(By.id("go")).click();
Thread.sleep(5000);
try {
assertTrue(driver.getPageSource().contains("Welcome"));
} catch (AssertionError e) {
System.out.println("login failed");
}
}
Commenting on the sleep and the naming but missing that the swallowed assertion makes the test always pass.
The miss: what the test claimed to check and what it really checked.
Discovery: how the gap came to light.
Fix: the test and the habit you changed.
Lesson: what you do now on every new test.
"Early on I wrote an API test for a discount rule. It posted an order and checked the response was successful and the total field existed. It passed for weeks. Then a customer reported the discount wasn't being applied, and when I looked, my test never compared the total to the expected value. It only checked the field was there. I was embarrassed, but I told the team straight away, fixed the assertion to check the exact expected total, and then went through my other tests looking for the same pattern. I found three more. The habit I changed is simple: when I write a new test, I break the code or the expected value on purpose once and make sure the test goes red. If I can't make it fail, it isn't testing anything."
Claiming it has never happened, or blaming the requirements for a weak assertion.
Situation: the change and why nobody expected it to break anything.
The catch: which check failed and what it showed.
Why it worked: what about that check made it catch this.
After: what you or the team changed.
"At my last company a developer upgraded a date library as a small housekeeping change, and nobody expected it to affect anything a user would see. Our API regression suite failed on one check: an invoice for the last day of the month was being dated to the first of the next month. It turned out the new version handled time zone conversion slightly differently. The check existed because, months earlier, I'd added boundary dates, month ends and year ends, to our test data builder after a similar bug. So every invoice test picked them up for free. We pinned the behaviour with a unit test in the date helper, and the developer fixed the conversion before it shipped. What I took from it is that putting edge cases into shared test data pays off much more than writing one-off tests for them."
A story where the bug was obvious and any test would have caught it, with no insight about why the check mattered.
The resistance: what developers disliked and why it was reasonable.
Remove friction: what you built or changed to make it easy.
Show value: the moment the tests paid off for them.
Result: how ownership changed.
"On one team, developers wouldn't touch the API tests because the framework was confusing and every test needed twenty lines of setup. Their complaint was fair. So instead of pushing a rule, I built a small set of builders so creating a user with an order was one line, and I wrote a short guide with three example tests they could copy. Then I paired with two developers on their next feature and we wrote the tests together in under an hour. A couple of weeks later, one of those tests caught a regression in another developer's change before it merged, and that did more than anything I could say. After a month, developers were adding API tests for their own features and I'd moved to reviewing them instead of writing them all."
A story where you escalated to a manager to force it, or where you just wrote all the tests yourself.
Private first: understand why, one to one, without an audience.
Evidence: real bugs or rework that tests would have caught.
Make it easy: helpers, examples, pairing.
Team agreement: a shared definition of done, raised in the retro if needed.
"I'd talk to them one to one first, not in a pull request comment where it becomes a public argument. I'd ask what gets in the way, because sometimes the answer is that the test setup is painful, and that's something I can fix. Then I'd bring evidence, like two bugs from the last month in their area that a simple unit test would have caught, and how much time it cost the team to find them late. I'd explain that my job is to make testing fast and reliable for everyone, not to be the only safety net, because a tester at the end can't catch everything a unit test can. If nothing changes, I'd raise it as a team question in the retro, agreeing a definition of done that includes tests, so it's a team rule and not me against one person."
Going straight to their manager, or quietly writing all their tests yourself to avoid the conversation.
Your view: the whole team owns quality.
Your role: make quality cheap and visible for everyone.
Daily practice: early involvement, tooling, review, coaching.
"I think the whole team owns quality, and my job is to make it easier for everyone to do their part. If I'm the only person responsible, I become a bottleneck at the end, and bugs get found late when they're most expensive to fix. So day to day that means I'm in refinement asking how a story could fail and how we'll test it, before any code exists. I build helpers and frameworks so developers can write good tests quickly. I review test code in pull requests the same way others review product code. And I keep the pipeline trustworthy, so a red build means something. I still test things myself and I still explore, but my biggest impact is when the team catches problems without needing me in the room."
Saying quality is the QA team's job, or that developers shouldn't have to think about testing.
ClapAssist is an AI interview assistant for Mac and Windows. It listens to the interview on your computer and shows you what to say, in short lines you can read while you talk. Your resume and notes are never stored on our servers. It stays out of screen share on every plan; only you can see it.