File size: 19,672 Bytes
cd0c7a9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 | """
Comprehensive BLAST test suite β loops over multiple datasets to verify
all BLAST features work end-to-end against live NCBI/EBI APIs.
Tests:
1. NCBI BLAST integration (submit β poll β fetch)
2. EBI BLAST tool (submit β poll β fetch)
3. Multiple protein sequences of varying lengths
4. Error handling (bad input, edge cases)
5. Pipeline v2 BLAST step
6. Retry logic verification
Run: pytest tests/test_blast_comprehensive.py -v -s --timeout=900
"""
import asyncio
import random
import string
import pytest
# ---------------------------------------------------------------------------
# Test datasets β real protein sequences of varying lengths
# ---------------------------------------------------------------------------
BLAST_TEST_SEQUENCES = [
{
"name": "crambin_short",
"sequence": "TTCCPSIVARSNFNVCRLPG",
"description": "Crambin first 20 residues (20 aa)",
"expected_program": "blastp",
"min_hits": 1,
},
{
"name": "human_insulin",
"sequence": "MALWMRLLPLLALLALWGPDPAAAFVNQHLCGSHLVEALYLVCGERGFFYTPKTRREAEDLQVGQVELGGGPGAGSLQPLALEGSLQKRGIVEQCCTSICSLYQLENYCN",
"description": "Human insulin preproinsulin (110 aa)",
"expected_program": "blastp",
"min_hits": 1,
},
{
"name": "gfp_fragment",
"sequence": "MSKGEELFTGVVPILVELDGDVNGHKFSVSGEGEGDATYGKLTLKFICTTGKLPVPWPTLVTTFSYGVQCFSRYPDHMKQHDFFKSAMPEGYVQERTIFFKDDGNYKTRAEVKFEGDTLVNRIELKGIDFKEDGNILGHKLEYNYNSHNVYIMADKQKNGIKVNFKIRHNIEDGSVQLADHYQQNTPIGDGPVLLPDNHYLSTQSALSKDPNEKRDHMVLLEFVTAAGITHGMDELYK",
"description": "Green fluorescent protein (238 aa)",
"expected_program": "blastp",
"min_hits": 1,
},
{
"name": "lysozyme",
"sequence": "KVFGRCELAAAMKRHGLDNYRGYSLGNWVCAAKFESNFNTQATNRNTDGSTDYGILQINSRWWCNDGRTPGSRNLCNIPCSALLSSDITASVNCAKKIVSDGNGMNAWVAWRNRCKGTDVQAWIRGCRL",
"description": "Hen egg white lysozyme (129 aa)",
"expected_program": "blastp",
"min_hits": 1,
},
{
"name": "human_hemoglobin_beta",
"sequence": "MVHLTPEEKSAVTALWGKVNVDEVGGEALGRLLVVYPWTQRFFESFGDLSTPDAVMGNPKVKAHGKKVLGAFSDGLAHLDNLKGTFATLSELHCDKLHVDPENFRLLGNVLVCVLAHHFGKEFTPPVQAAYQKVVAGVANALAHKYH",
"description": "Human hemoglobin beta subunit (147 aa)",
"expected_program": "blastp",
"min_hits": 1,
},
]
# DNA sequence for blastn testing
DNA_TEST_SEQUENCES = [
{
"name": "small_rna",
"sequence": "ATGGCGACCGGCGCTCCCGCCGGGATCGCCATG",
"description": "Short DNA fragment (33 bp)",
"expected_program": "blastn",
"min_hits": 0, # DNA may or may not hit depending on db
},
]
# ---------------------------------------------------------------------------
# 1. NCBI BLAST integration β direct API tests
# ---------------------------------------------------------------------------
class TestNCBIBlastIntegration:
"""Test the NCBI BLAST integration module directly (submit β poll β fetch)."""
@pytest.mark.asyncio
@pytest.mark.parametrize(
"dataset",
BLAST_TEST_SEQUENCES[:3], # First 3 for speed
ids=[d["name"] for d in BLAST_TEST_SEQUENCES[:3]],
)
async def test_ncbi_blast_submit_and_poll(self, dataset):
"""Submit a BLAST job and poll until READY or TIMEOUT."""
from app.integrations.ncbi.blast import submit_blast, check_status_until_ready
seq = dataset["sequence"]
program = dataset["expected_program"]
db = "swissprot" # Use swissprot for faster results in tests
submit_result = await submit_blast(
seq, program=program, database=db, hitlist_size=10,
)
if "error" in submit_result:
# NCBI may rate-limit; skip if submit fails
pytest.skip(f"NCBI submit failed: {submit_result['error']}")
rid = submit_result["rid"]
assert rid, "RID should not be empty"
assert len(rid) > 5, f"RID looks too short: {rid}"
# Poll with generous timeout (2 min for tests)
status_result = await check_status_until_ready(rid, max_wait_seconds=120)
status = status_result.get("status", "UNKNOWN")
assert status in ("READY", "TIMEOUT", "POLL_FAILED", "ERROR", "FAILED"), \
f"Unexpected status: {status}"
@pytest.mark.asyncio
async def test_ncbi_blast_full_roundtrip_short_seq(self):
"""Full roundtrip: submit β poll β fetch results for crambin fragment."""
from app.integrations.ncbi.blast import run_blast_with_retry
result = await run_blast_with_retry(
"TTCCPSIVARSNFNVCRLPG",
retries=2,
max_wait_seconds=180,
program="blastp",
database="swissprot",
hitlist_size=10,
)
if "error" in result:
pytest.skip(f"NCBI BLAST roundtrip failed (external API issue): {result['error']}")
assert "raw" in result, "Should return raw XML"
assert len(result["raw"]) > 100, "Raw XML too short"
@pytest.mark.asyncio
async def test_ncbi_blast_parse_xml(self):
"""Verify XML parsing of NCBI BLAST results."""
from app.integrations.ncbi.blast import run_blast_with_retry
from app.integrations.ncbi.parser import parse_blast_xml
result = await run_blast_with_retry(
"MVHLTPEEKSAVTALWGKVNVDEVGGEALGRLLVVYPWTQRFFESFGDLSTPDAVMGNPKVKAHGKKVLGAFSDGLAHLDNLKGTFATLSELHCDKLHVDPENFRLLGNVLVCVLAHHFGKEFTPPVQAAYQKVVAGVANALAHKYH",
retries=2,
max_wait_seconds=180,
program="blastp",
database="swissprot",
hitlist_size=10,
)
if "error" in result:
pytest.skip(f"BLAST failed: {result['error']}")
parsed = parse_blast_xml(result["raw"])
assert "error" not in parsed, f"Parse error: {parsed.get('error')}"
assert parsed["count"] > 0, "Should find at least one hit"
assert parsed["query_length"] > 0, "Query length should be positive"
hit = parsed["hits"][0]
assert hit["accession"], "Hit should have accession"
assert hit["evalue"] >= 0, "E-value should be non-negative"
assert hit["bit_score"] > 0, "Bit score should be positive"
@pytest.mark.asyncio
async def test_ncbi_blast_retry_on_failure(self):
"""Verify that retry logic works β submitting with invalid sequence should fail gracefully."""
from app.integrations.ncbi.blast import run_blast_with_retry
# Submit with clearly invalid sequence (too short / garbage)
result = await run_blast_with_retry(
"XXXX",
retries=1,
max_wait_seconds=30,
program="blastp",
database="swissprot",
)
# Should either get an error or no hits β both are acceptable
# The key is it shouldn't crash with an unhandled exception
assert isinstance(result, dict), "Result should be a dict"
@pytest.mark.asyncio
async def test_ncbi_blast_timeout_handling(self):
"""Verify timeout returns proper error, not an unhandled exception."""
from app.integrations.ncbi.blast import run_blast_with_retry
result = await run_blast_with_retry(
"TTCCPSIVARSNFNVCRLPG",
retries=0,
max_wait_seconds=5, # Very short timeout β will almost certainly time out
program="blastp",
database="nr", # nr is slower than swissprot
)
# Should return a dict with error or hits β never crash
assert isinstance(result, dict), "Should return dict even on timeout"
# Either it finished very fast (unlikely) or it timed out gracefully
assert "error" in result or "raw" in result
# ---------------------------------------------------------------------------
# 2. EBI BLAST tool tests
# ---------------------------------------------------------------------------
class TestEBIBlastTool:
"""Test the EBI BLAST tool (BlastTool class)."""
@pytest.mark.asyncio
@pytest.mark.parametrize(
"dataset",
BLAST_TEST_SEQUENCES[:2],
ids=[d["name"] for d in BLAST_TEST_SEQUENCES[:2]],
)
async def test_ebi_blast_returns_hits(self, dataset):
"""EBI BLAST should find hits for known proteins."""
from app.tools.blast import BlastTool
tool = BlastTool()
result = await tool.run({
"sequence": dataset["sequence"],
"program": "blastp",
"database": "uniprotkb_swissprot",
"max_hits": 5,
})
assert "hits" in result or "error" in result
if "hits" in result:
assert len(result["hits"]) > 0, "Should find at least one hit"
assert result["count"] == len(result["hits"])
@pytest.mark.asyncio
async def test_ebi_blast_hit_structure(self):
"""Verify BLAST hit data structure is correct."""
from app.tools.blast import BlastTool
tool = BlastTool()
result = await tool.run({
"sequence": "TTCCPSIVARSNFNVCRLPG",
"program": "blastp",
"database": "uniprotkb_swissprot",
"max_hits": 3,
})
if "error" in result:
pytest.skip(f"EBI BLAST failed: {result['error']}")
for hit in result["hits"]:
assert "accession" in hit
assert "evalue" in hit
assert "bit_score" in hit
assert "identity_pct" in hit
assert hit["evalue"] < 1.0, f"E-value too high: {hit['evalue']}"
@pytest.mark.asyncio
async def test_ebi_blast_poll_resilience(self):
"""Verify EBI BLAST poll handles transient failures."""
from app.tools.blast import BlastTool
tool = BlastTool()
# This tests the enhanced poll with failure tolerance
result = await tool.run({
"sequence": "MALWMRLLPLLALLALWGPDPAAAFVNQHLCGSHLVEALYLVCGERGFFYTPKTRREAEDLQVGQVELGGGPGAGSLQPLALEGSLQKRGIVEQCCTSICSLYQLENYCN",
"program": "blastp",
"database": "uniprotkb_swissprot",
"max_hits": 5,
})
# Should not crash β either hits or graceful error
assert isinstance(result, dict)
# ---------------------------------------------------------------------------
# 3. Pipeline v2 BLAST step integration
# ---------------------------------------------------------------------------
class TestPipelineV2BLAST:
"""Test BLAST as part of the pipeline v2 flow."""
@pytest.mark.asyncio
async def test_pipeline_blast_step_only(self):
"""Run only the BLAST step via pipeline_v2."""
from app.integrations.ncbi.blast import run_blast_with_retry
from app.integrations.ncbi.parser import parse_blast_xml
# Simulate what pipeline_v2._run_blast does
sequence = "TTCCPSIVARSNFNVCRLPG"
database = "swissprot"
results = await run_blast_with_retry(
sequence,
retries=2,
max_wait_seconds=180,
database=database,
)
if "error" in results:
pytest.skip(f"Pipeline BLAST step failed: {results['error']}")
parsed = parse_blast_xml(results["raw"])
assert "error" not in parsed
assert parsed["count"] > 0
hits = parsed.get("hits", [])
top_hit = hits[0] if hits else None
# Verify the data structure matches what pipeline_v2 expects
if top_hit:
assert "accession" in top_hit
assert "evalue" in top_hit
assert "identity_pct" in top_hit
assert "bit_score" in top_hit
@pytest.mark.asyncio
async def test_pipeline_blast_multiple_sequences_loop(self):
"""Loop: run BLAST for each test sequence and verify results."""
from app.integrations.ncbi.blast import run_blast_with_retry
from app.integrations.ncbi.parser import parse_blast_xml
results_summary = []
for dataset in BLAST_TEST_SEQUENCES:
sequence = dataset["sequence"]
name = dataset["name"]
try:
blast_result = await run_blast_with_retry(
sequence,
retries=2,
max_wait_seconds=180,
program=dataset["expected_program"],
database="swissprot",
hitlist_size=10,
)
if "error" in blast_result:
results_summary.append({
"name": name,
"status": "error",
"error": blast_result["error"],
})
continue
parsed = parse_blast_xml(blast_result["raw"])
hit_count = parsed.get("count", 0)
results_summary.append({
"name": name,
"status": "ok" if hit_count >= dataset["min_hits"] else "low_hits",
"hits": hit_count,
})
except Exception as e:
results_summary.append({
"name": name,
"status": "exception",
"error": str(e),
})
# Rate limit: wait between requests
await asyncio.sleep(2)
# Report results
print("\n=== BLAST Loop Test Results ===")
for r in results_summary:
print(f" {r['name']}: {r['status']} (hits={r.get('hits', 'N/A')}, error={r.get('error', 'none')})")
# At least some should succeed (unless NCBI is completely down)
ok_count = sum(1 for r in results_summary if r["status"] == "ok")
assert ok_count >= 1, f"No BLAST tests succeeded: {results_summary}"
# ---------------------------------------------------------------------------
# 4. Error handling & edge cases
# ---------------------------------------------------------------------------
class TestBlastErrorHandling:
"""Test BLAST error handling with bad inputs."""
@pytest.mark.asyncio
async def test_empty_sequence(self):
"""BLAST should handle empty sequence gracefully."""
from app.integrations.ncbi.blast import run_blast_with_retry
result = await run_blast_with_retry(
"",
retries=0,
max_wait_seconds=10,
database="swissprot",
)
assert isinstance(result, dict)
assert "error" in result or "raw" in result
@pytest.mark.asyncio
async def test_invalid_characters(self):
"""BLAST should handle non-biological characters."""
from app.integrations.ncbi.blast import run_blast_with_retry
result = await run_blast_with_retry(
"12345!@#$%^&*()",
retries=0,
max_wait_seconds=30,
program="blastp",
database="swissprot",
)
assert isinstance(result, dict)
@pytest.mark.asyncio
async def test_very_long_sequence(self):
"""BLAST should handle long sequences (may be slow but shouldn't crash)."""
from app.integrations.ncbi.blast import run_blast_with_retry
# Generate a 500 aa random protein sequence
aa_chars = "ACDEFGHIKLMNPQRSTVWY"
long_seq = "".join(random.choice(aa_chars) for _ in range(500))
result = await run_blast_with_retry(
long_seq,
retries=1,
max_wait_seconds=120,
program="blastp",
database="swissprot",
)
assert isinstance(result, dict)
@pytest.mark.asyncio
async def test_dna_blastn(self):
"""Test blastn with a short DNA sequence."""
from app.integrations.ncbi.blast import run_blast_with_retry
result = await run_blast_with_retry(
"ATGGCGACCGGCGCTCCCGCCGGGATCGCCATG",
retries=1,
max_wait_seconds=120,
program="blastn",
database="nt",
hitlist_size=5,
)
assert isinstance(result, dict)
# ---------------------------------------------------------------------------
# 5. Random data fuzz testing
# ---------------------------------------------------------------------------
class TestBlastRandomFuzz:
"""Fuzz test: BLAST with random sequences to verify no crashes."""
@pytest.mark.asyncio
@pytest.mark.parametrize("i", range(5))
async def test_random_protein_sequence(self, i):
"""Submit 5 random protein sequences β none should crash."""
from app.integrations.ncbi.blast import run_blast_with_retry
aa_chars = "ACDEFGHIKLMNPQRSTVWY"
length = random.randint(10, 80)
random_seq = "".join(random.choice(aa_chars) for _ in range(length))
result = await run_blast_with_retry(
random_seq,
retries=1,
max_wait_seconds=90,
program="blastp",
database="swissprot",
hitlist_size=5,
)
# Must return a dict β no unhandled exceptions
assert isinstance(result, dict), f"BLAST crashed on random seq: {random_seq}"
# Should have either error or raw (results)
assert "error" in result or "raw" in result
@pytest.mark.asyncio
async def test_random_edge_case_lengths(self):
"""Test BLAST with edge-case sequence lengths."""
from app.integrations.ncbi.blast import run_blast_with_retry
aa_chars = "ACDEFGHIKLMNPQRSTVWY"
edge_cases = [
("min_valid", "".join(random.choice(aa_chars) for _ in range(6))), # minimum
("medium", "".join(random.choice(aa_chars) for _ in range(50))),
("long", "".join(random.choice(aa_chars) for _ in range(200))),
]
for name, seq in edge_cases:
result = await run_blast_with_retry(
seq,
retries=1,
max_wait_seconds=90,
program="blastp",
database="swissprot",
hitlist_size=5,
)
assert isinstance(result, dict), f"BLAST crashed on {name} ({len(seq)} aa)"
await asyncio.sleep(2)
# ---------------------------------------------------------------------------
# 6. Consecutive reliability test
# ---------------------------------------------------------------------------
class TestBlastReliability:
"""Run BLAST multiple times in sequence to verify consistent behavior."""
@pytest.mark.asyncio
async def test_consecutive_blast_runs(self):
"""Run BLAST 3 times with the same sequence β should all succeed or all fail consistently."""
from app.integrations.ncbi.blast import run_blast_with_retry
from app.integrations.ncbi.parser import parse_blast_xml
sequence = "TTCCPSIVARSNFNVCRLPG"
results = []
for i in range(3):
result = await run_blast_with_retry(
sequence,
retries=2,
max_wait_seconds=120,
program="blastp",
database="swissprot",
hitlist_size=5,
)
results.append(result)
await asyncio.sleep(3)
# Count successes vs failures
successes = sum(1 for r in results if "raw" in r)
failures = sum(1 for r in results if "error" in r)
print(f"\n=== Reliability: {successes} successes, {failures} failures out of 3 ===")
# At least 2/3 should succeed (NCBI occasional hiccups are OK)
assert successes >= 2, f"Too many BLAST failures: {failures}/3 failed"
|